├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── errors.go ├── errors_test.go ├── event.go ├── examples ├── alternate.go ├── cancel_async_transition.go ├── data.go ├── simple.go ├── struct.go └── transition_callbacks.go ├── fsm.go ├── fsm_test.go ├── go.mod ├── graphviz_visualizer.go ├── graphviz_visualizer_test.go ├── mermaid_visualizer.go ├── mermaid_visualizer_test.go ├── uncancel_context.go ├── uncancel_context_test.go └── visualizer.go /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@v2 17 | with: 18 | go-version: 1.16 19 | 20 | - name: Test 21 | run: go test -race -coverprofile=coverage.out ./... 22 | 23 | - name: Convert coverage 24 | uses: jandelgado/gcov2lcov-action@v1.0.5 25 | 26 | - name: Upload coverage 27 | uses: coverallsapp/github-action@master 28 | with: 29 | github-token: ${{ secrets.github_token }} 30 | path-to-lcov: coverage.lcov 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | 24 | *.sublime-project 25 | *.sublime-workspace 26 | 27 | .DS_Store 28 | .wercker 29 | 30 | # Testing 31 | .coverprofile 32 | 33 | .vscode -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, and 10 | distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by the copyright 13 | owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all other entities 16 | that control, are controlled by, or are under common control with that entity. 17 | For the purposes of this definition, "control" means (i) the power, direct or 18 | indirect, to cause the direction or management of such entity, whether by 19 | contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the 20 | outstanding shares, or (iii) beneficial ownership of such entity. 21 | 22 | "You" (or "Your") shall mean an individual or Legal Entity exercising 23 | permissions granted by this License. 24 | 25 | "Source" form shall mean the preferred form for making modifications, including 26 | but not limited to software source code, documentation source, and configuration 27 | files. 28 | 29 | "Object" form shall mean any form resulting from mechanical transformation or 30 | translation of a Source form, including but not limited to compiled object code, 31 | generated documentation, and conversions to other media types. 32 | 33 | "Work" shall mean the work of authorship, whether in Source or Object form, made 34 | available under the License, as indicated by a copyright notice that is included 35 | in or attached to the work (an example is provided in the Appendix below). 36 | 37 | "Derivative Works" shall mean any work, whether in Source or Object form, that 38 | is based on (or derived from) the Work and for which the editorial revisions, 39 | annotations, elaborations, or other modifications represent, as a whole, an 40 | original work of authorship. For the purposes of this License, Derivative Works 41 | shall not include works that remain separable from, or merely link (or bind by 42 | name) to the interfaces of, the Work and Derivative Works thereof. 43 | 44 | "Contribution" shall mean any work of authorship, including the original version 45 | of the Work and any modifications or additions to that Work or Derivative Works 46 | thereof, that is intentionally submitted to Licensor for inclusion in the Work 47 | by the copyright owner or by an individual or Legal Entity authorized to submit 48 | on behalf of the copyright owner. For the purposes of this definition, 49 | "submitted" means any form of electronic, verbal, or written communication sent 50 | to the Licensor or its representatives, including but not limited to 51 | communication on electronic mailing lists, source code control systems, and 52 | issue tracking systems that are managed by, or on behalf of, the Licensor for 53 | the purpose of discussing and improving the Work, but excluding communication 54 | that is conspicuously marked or otherwise designated in writing by the copyright 55 | owner as "Not a Contribution." 56 | 57 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf 58 | of whom a Contribution has been received by Licensor and subsequently 59 | incorporated within the Work. 60 | 61 | 2. Grant of Copyright License. 62 | 63 | Subject to the terms and conditions of this License, each Contributor hereby 64 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 65 | irrevocable copyright license to reproduce, prepare Derivative Works of, 66 | publicly display, publicly perform, sublicense, and distribute the Work and such 67 | Derivative Works in Source or Object form. 68 | 69 | 3. Grant of Patent License. 70 | 71 | Subject to the terms and conditions of this License, each Contributor hereby 72 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 73 | irrevocable (except as stated in this section) patent license to make, have 74 | made, use, offer to sell, sell, import, and otherwise transfer the Work, where 75 | such license applies only to those patent claims licensable by such Contributor 76 | that are necessarily infringed by their Contribution(s) alone or by combination 77 | of their Contribution(s) with the Work to which such Contribution(s) was 78 | submitted. If You institute patent litigation against any entity (including a 79 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 80 | Contribution incorporated within the Work constitutes direct or contributory 81 | patent infringement, then any patent licenses granted to You under this License 82 | for that Work shall terminate as of the date such litigation is filed. 83 | 84 | 4. Redistribution. 85 | 86 | You may reproduce and distribute copies of the Work or Derivative Works thereof 87 | in any medium, with or without modifications, and in Source or Object form, 88 | provided that You meet the following conditions: 89 | 90 | You must give any other recipients of the Work or Derivative Works a copy of 91 | this License; and 92 | You must cause any modified files to carry prominent notices stating that You 93 | changed the files; and 94 | You must retain, in the Source form of any Derivative Works that You distribute, 95 | all copyright, patent, trademark, and attribution notices from the Source form 96 | of the Work, excluding those notices that do not pertain to any part of the 97 | Derivative Works; and 98 | If the Work includes a "NOTICE" text file as part of its distribution, then any 99 | Derivative Works that You distribute must include a readable copy of the 100 | attribution notices contained within such NOTICE file, excluding those notices 101 | that do not pertain to any part of the Derivative Works, in at least one of the 102 | following places: within a NOTICE text file distributed as part of the 103 | Derivative Works; within the Source form or documentation, if provided along 104 | with the Derivative Works; or, within a display generated by the Derivative 105 | Works, if and wherever such third-party notices normally appear. The contents of 106 | the NOTICE file are for informational purposes only and do not modify the 107 | License. You may add Your own attribution notices within Derivative Works that 108 | You distribute, alongside or as an addendum to the NOTICE text from the Work, 109 | provided that such additional attribution notices cannot be construed as 110 | modifying the License. 111 | You may add Your own copyright statement to Your modifications and may provide 112 | additional or different license terms and conditions for use, reproduction, or 113 | distribution of Your modifications, or for any such Derivative Works as a whole, 114 | provided Your use, reproduction, and distribution of the Work otherwise complies 115 | with the conditions stated in this License. 116 | 117 | 5. Submission of Contributions. 118 | 119 | Unless You explicitly state otherwise, any Contribution intentionally submitted 120 | for inclusion in the Work by You to the Licensor shall be under the terms and 121 | conditions of this License, without any additional terms or conditions. 122 | Notwithstanding the above, nothing herein shall supersede or modify the terms of 123 | any separate license agreement you may have executed with Licensor regarding 124 | such Contributions. 125 | 126 | 6. Trademarks. 127 | 128 | This License does not grant permission to use the trade names, trademarks, 129 | service marks, or product names of the Licensor, except as required for 130 | reasonable and customary use in describing the origin of the Work and 131 | reproducing the content of the NOTICE file. 132 | 133 | 7. Disclaimer of Warranty. 134 | 135 | Unless required by applicable law or agreed to in writing, Licensor provides the 136 | Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, 137 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, 138 | including, without limitation, any warranties or conditions of TITLE, 139 | NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are 140 | solely responsible for determining the appropriateness of using or 141 | redistributing the Work and assume any risks associated with Your exercise of 142 | permissions under this License. 143 | 144 | 8. Limitation of Liability. 145 | 146 | In no event and under no legal theory, whether in tort (including negligence), 147 | contract, or otherwise, unless required by applicable law (such as deliberate 148 | and grossly negligent acts) or agreed to in writing, shall any Contributor be 149 | liable to You for damages, including any direct, indirect, special, incidental, 150 | or consequential damages of any character arising as a result of this License or 151 | out of the use or inability to use the Work (including but not limited to 152 | damages for loss of goodwill, work stoppage, computer failure or malfunction, or 153 | any and all other commercial damages or losses), even if such Contributor has 154 | been advised of the possibility of such damages. 155 | 156 | 9. Accepting Warranty or Additional Liability. 157 | 158 | While redistributing the Work or Derivative Works thereof, You may choose to 159 | offer, and charge a fee for, acceptance of support, warranty, indemnity, or 160 | other liability obligations and/or rights consistent with this License. However, 161 | in accepting such obligations, You may act only on Your own behalf and on Your 162 | sole responsibility, not on behalf of any other Contributor, and only if You 163 | agree to indemnify, defend, and hold each Contributor harmless for any liability 164 | incurred by, or claims asserted against, such Contributor by reason of your 165 | accepting any such warranty or additional liability. 166 | 167 | END OF TERMS AND CONDITIONS 168 | 169 | APPENDIX: How to apply the Apache License to your work 170 | 171 | To apply the Apache License to your work, attach the following boilerplate 172 | notice, with the fields enclosed by brackets "[]" replaced with your own 173 | identifying information. (Don't include the brackets!) The text should be 174 | enclosed in the appropriate comment syntax for the file format. We also 175 | recommend that a file or class name and description of purpose be included on 176 | the same "printed page" as the copyright notice for easier identification within 177 | third-party archives. 178 | 179 | Copyright [yyyy] [name of copyright owner] 180 | 181 | Licensed under the Apache License, Version 2.0 (the "License"); 182 | you may not use this file except in compliance with the License. 183 | You may obtain a copy of the License at 184 | 185 | http://www.apache.org/licenses/LICENSE-2.0 186 | 187 | Unless required by applicable law or agreed to in writing, software 188 | distributed under the License is distributed on an "AS IS" BASIS, 189 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 190 | See the License for the specific language governing permissions and 191 | limitations under the License. 192 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: services test 2 | 3 | .PHONY: test 4 | test: 5 | go test -race ./... 6 | 7 | .PHONY: lint 8 | lint: 9 | golangci-lint run 10 | 11 | .PHONY: cover 12 | cover: 13 | go list -f '{{if len .TestGoFiles}}"go test -coverprofile={{.Dir}}/.coverprofile {{.ImportPath}}"{{end}}' ./... | xargs -L 1 sh -c 14 | 15 | .PHONY: publish_cover 16 | publish_cover: cover 17 | go get -d golang.org/x/tools/cmd/cover 18 | go get github.com/modocache/gover 19 | go get github.com/mattn/goveralls 20 | gover 21 | @goveralls -coverprofile=gover.coverprofile -service=travis-ci -repotoken=$(COVERALLS_TOKEN) 22 | 23 | .PHONY: clean 24 | clean: 25 | @find . -name \.coverprofile -type f -delete 26 | @rm -f gover.coverprofile 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PkgGoDev](https://pkg.go.dev/badge/github.com/looplab/fsm)](https://pkg.go.dev/github.com/looplab/fsm) 2 | ![Bulid Status](https://github.com/looplab/fsm/actions/workflows/main.yml/badge.svg) 3 | [![Coverage Status](https://img.shields.io/coveralls/looplab/fsm.svg)](https://coveralls.io/r/looplab/fsm) 4 | [![Go Report Card](https://goreportcard.com/badge/looplab/fsm)](https://goreportcard.com/report/looplab/fsm) 5 | 6 | # FSM for Go 7 | 8 | FSM is a finite state machine for Go. 9 | 10 | It is heavily based on two FSM implementations: 11 | 12 | - Javascript Finite State Machine, https://github.com/jakesgordon/javascript-state-machine 13 | 14 | - Fysom for Python, https://github.com/oxplot/fysom (forked at https://github.com/mriehl/fysom) 15 | 16 | For API docs and examples see http://godoc.org/github.com/looplab/fsm 17 | 18 | # Basic Example 19 | 20 | From examples/simple.go: 21 | 22 | ```go 23 | package main 24 | 25 | import ( 26 | "context" 27 | "fmt" 28 | 29 | "github.com/looplab/fsm" 30 | ) 31 | 32 | func main() { 33 | fsm := fsm.NewFSM( 34 | "closed", 35 | fsm.Events{ 36 | {Name: "open", Src: []string{"closed"}, Dst: "open"}, 37 | {Name: "close", Src: []string{"open"}, Dst: "closed"}, 38 | }, 39 | fsm.Callbacks{}, 40 | ) 41 | 42 | fmt.Println(fsm.Current()) 43 | 44 | err := fsm.Event(context.Background(), "open") 45 | if err != nil { 46 | fmt.Println(err) 47 | } 48 | 49 | fmt.Println(fsm.Current()) 50 | 51 | err = fsm.Event(context.Background(), "close") 52 | if err != nil { 53 | fmt.Println(err) 54 | } 55 | 56 | fmt.Println(fsm.Current()) 57 | } 58 | ``` 59 | 60 | # Usage as a struct field 61 | 62 | From examples/struct.go: 63 | 64 | ```go 65 | package main 66 | 67 | import ( 68 | "context" 69 | "fmt" 70 | 71 | "github.com/looplab/fsm" 72 | ) 73 | 74 | type Door struct { 75 | To string 76 | FSM *fsm.FSM 77 | } 78 | 79 | func NewDoor(to string) *Door { 80 | d := &Door{ 81 | To: to, 82 | } 83 | 84 | d.FSM = fsm.NewFSM( 85 | "closed", 86 | fsm.Events{ 87 | {Name: "open", Src: []string{"closed"}, Dst: "open"}, 88 | {Name: "close", Src: []string{"open"}, Dst: "closed"}, 89 | }, 90 | fsm.Callbacks{ 91 | "enter_state": func(_ context.Context, e *fsm.Event) { d.enterState(e) }, 92 | }, 93 | ) 94 | 95 | return d 96 | } 97 | 98 | func (d *Door) enterState(e *fsm.Event) { 99 | fmt.Printf("The door to %s is %s\n", d.To, e.Dst) 100 | } 101 | 102 | func main() { 103 | door := NewDoor("heaven") 104 | 105 | err := door.FSM.Event(context.Background(), "open") 106 | if err != nil { 107 | fmt.Println(err) 108 | } 109 | 110 | err = door.FSM.Event(context.Background(), "close") 111 | if err != nil { 112 | fmt.Println(err) 113 | } 114 | } 115 | ``` 116 | 117 | # License 118 | 119 | FSM is licensed under Apache License 2.0 120 | 121 | http://www.apache.org/licenses/LICENSE-2.0 122 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013 - Max Persson 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package fsm 16 | 17 | import ( 18 | "context" 19 | ) 20 | 21 | // InvalidEventError is returned by FSM.Event() when the event cannot be called 22 | // in the current state. 23 | type InvalidEventError struct { 24 | Event string 25 | State string 26 | } 27 | 28 | func (e InvalidEventError) Error() string { 29 | return "event " + e.Event + " inappropriate in current state " + e.State 30 | } 31 | 32 | // UnknownEventError is returned by FSM.Event() when the event is not defined. 33 | type UnknownEventError struct { 34 | Event string 35 | } 36 | 37 | func (e UnknownEventError) Error() string { 38 | return "event " + e.Event + " does not exist" 39 | } 40 | 41 | // InTransitionError is returned by FSM.Event() when an asynchronous transition 42 | // is already in progress. 43 | type InTransitionError struct { 44 | Event string 45 | } 46 | 47 | func (e InTransitionError) Error() string { 48 | return "event " + e.Event + " inappropriate because previous transition did not complete" 49 | } 50 | 51 | // NotInTransitionError is returned by FSM.Transition() when an asynchronous 52 | // transition is not in progress. 53 | type NotInTransitionError struct{} 54 | 55 | func (e NotInTransitionError) Error() string { 56 | return "transition inappropriate because no state change in progress" 57 | } 58 | 59 | // NoTransitionError is returned by FSM.Event() when no transition have happened, 60 | // for example if the source and destination states are the same. 61 | type NoTransitionError struct { 62 | Err error 63 | } 64 | 65 | func (e NoTransitionError) Error() string { 66 | if e.Err != nil { 67 | return "no transition with error: " + e.Err.Error() 68 | } 69 | return "no transition" 70 | } 71 | 72 | func (e NoTransitionError) Unwrap() error { 73 | return e.Err 74 | } 75 | 76 | // CanceledError is returned by FSM.Event() when a callback have canceled a 77 | // transition. 78 | type CanceledError struct { 79 | Err error 80 | } 81 | 82 | func (e CanceledError) Error() string { 83 | if e.Err != nil { 84 | return "transition canceled with error: " + e.Err.Error() 85 | } 86 | return "transition canceled" 87 | } 88 | 89 | func (e CanceledError) Unwrap() error { 90 | return e.Err 91 | } 92 | 93 | // AsyncError is returned by FSM.Event() when a callback have initiated an 94 | // asynchronous state transition. 95 | type AsyncError struct { 96 | Err error 97 | 98 | Ctx context.Context 99 | CancelTransition func() 100 | } 101 | 102 | func (e AsyncError) Error() string { 103 | if e.Err != nil { 104 | return "async started with error: " + e.Err.Error() 105 | } 106 | return "async started" 107 | } 108 | 109 | func (e AsyncError) Unwrap() error { 110 | return e.Err 111 | } 112 | 113 | // InternalError is returned by FSM.Event() and should never occur. It is a 114 | // probably because of a bug. 115 | type InternalError struct{} 116 | 117 | func (e InternalError) Error() string { 118 | return "internal error on state transition" 119 | } 120 | -------------------------------------------------------------------------------- /errors_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013 - Max Persson 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package fsm 16 | 17 | import ( 18 | "errors" 19 | "testing" 20 | ) 21 | 22 | func TestInvalidEventError(t *testing.T) { 23 | event := "invalid event" 24 | state := "state" 25 | e := InvalidEventError{Event: event, State: state} 26 | if e.Error() != "event "+e.Event+" inappropriate in current state "+e.State { 27 | t.Error("InvalidEventError string mismatch") 28 | } 29 | } 30 | 31 | func TestUnknownEventError(t *testing.T) { 32 | event := "invalid event" 33 | e := UnknownEventError{Event: event} 34 | if e.Error() != "event "+e.Event+" does not exist" { 35 | t.Error("UnknownEventError string mismatch") 36 | } 37 | } 38 | 39 | func TestInTransitionError(t *testing.T) { 40 | event := "in transition" 41 | e := InTransitionError{Event: event} 42 | if e.Error() != "event "+e.Event+" inappropriate because previous transition did not complete" { 43 | t.Error("InTransitionError string mismatch") 44 | } 45 | } 46 | 47 | func TestNotInTransitionError(t *testing.T) { 48 | e := NotInTransitionError{} 49 | if e.Error() != "transition inappropriate because no state change in progress" { 50 | t.Error("NotInTransitionError string mismatch") 51 | } 52 | } 53 | 54 | func TestNoTransitionError(t *testing.T) { 55 | e := NoTransitionError{} 56 | if e.Error() != "no transition" { 57 | t.Error("NoTransitionError string mismatch") 58 | } 59 | e.Err = errors.New("no transition") 60 | if e.Error() != "no transition with error: "+e.Err.Error() { 61 | t.Error("NoTransitionError string mismatch") 62 | } 63 | if e.Unwrap() == nil { 64 | t.Error("CanceledError Unwrap() should not be nil") 65 | } 66 | if !errors.Is(e, e.Err) { 67 | t.Error("CanceledError should be equal to its error") 68 | } 69 | } 70 | 71 | func TestCanceledError(t *testing.T) { 72 | e := CanceledError{} 73 | if e.Error() != "transition canceled" { 74 | t.Error("CanceledError string mismatch") 75 | } 76 | e.Err = errors.New("canceled") 77 | if e.Error() != "transition canceled with error: "+e.Err.Error() { 78 | t.Error("CanceledError string mismatch") 79 | } 80 | if e.Unwrap() == nil { 81 | t.Error("CanceledError Unwrap() should not be nil") 82 | } 83 | if !errors.Is(e, e.Err) { 84 | t.Error("CanceledError should be equal to its error") 85 | } 86 | } 87 | 88 | func TestAsyncError(t *testing.T) { 89 | e := AsyncError{} 90 | if e.Error() != "async started" { 91 | t.Error("AsyncError string mismatch") 92 | } 93 | e.Err = errors.New("async") 94 | if e.Error() != "async started with error: "+e.Err.Error() { 95 | t.Error("AsyncError string mismatch") 96 | } 97 | if e.Unwrap() == nil { 98 | t.Error("AsyncError Unwrap() should not be nil") 99 | } 100 | if !errors.Is(e, e.Err) { 101 | t.Error("AsyncError should be equal to its error") 102 | } 103 | } 104 | 105 | func TestInternalError(t *testing.T) { 106 | e := InternalError{} 107 | if e.Error() != "internal error on state transition" { 108 | t.Error("InternalError string mismatch") 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /event.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013 - Max Persson 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package fsm 16 | 17 | // Event is the info that get passed as a reference in the callbacks. 18 | type Event struct { 19 | // FSM is an reference to the current FSM. 20 | FSM *FSM 21 | 22 | // Event is the event name. 23 | Event string 24 | 25 | // Src is the state before the transition. 26 | Src string 27 | 28 | // Dst is the state after the transition. 29 | Dst string 30 | 31 | // Err is an optional error that can be returned from a callback. 32 | Err error 33 | 34 | // Args is an optional list of arguments passed to the callback. 35 | Args []interface{} 36 | 37 | // canceled is an internal flag set if the transition is canceled. 38 | canceled bool 39 | 40 | // async is an internal flag set if the transition should be asynchronous 41 | async bool 42 | 43 | // cancelFunc is called in case the event is canceled. 44 | cancelFunc func() 45 | } 46 | 47 | // Cancel can be called in before_ or leave_ to cancel the 48 | // current transition before it happens. It takes an optional error, which will 49 | // overwrite e.Err if set before. 50 | func (e *Event) Cancel(err ...error) { 51 | e.canceled = true 52 | e.cancelFunc() 53 | 54 | if len(err) > 0 { 55 | e.Err = err[0] 56 | } 57 | } 58 | 59 | // Async can be called in leave_ to do an asynchronous state transition. 60 | // 61 | // The current state transition will be on hold in the old state until a final 62 | // call to Transition is made. This will complete the transition and possibly 63 | // call the other callbacks. 64 | func (e *Event) Async() { 65 | e.async = true 66 | } 67 | -------------------------------------------------------------------------------- /examples/alternate.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | // +build ignore 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | 10 | "github.com/looplab/fsm" 11 | ) 12 | 13 | func main() { 14 | fsm := fsm.NewFSM( 15 | "idle", 16 | fsm.Events{ 17 | {Name: "scan", Src: []string{"idle"}, Dst: "scanning"}, 18 | {Name: "working", Src: []string{"scanning"}, Dst: "scanning"}, 19 | {Name: "situation", Src: []string{"scanning"}, Dst: "scanning"}, 20 | {Name: "situation", Src: []string{"idle"}, Dst: "idle"}, 21 | {Name: "finish", Src: []string{"scanning"}, Dst: "idle"}, 22 | }, 23 | fsm.Callbacks{ 24 | "scan": func(_ context.Context, e *fsm.Event) { 25 | fmt.Println("after_scan: " + e.FSM.Current()) 26 | }, 27 | "working": func(_ context.Context, e *fsm.Event) { 28 | fmt.Println("working: " + e.FSM.Current()) 29 | }, 30 | "situation": func(_ context.Context, e *fsm.Event) { 31 | fmt.Println("situation: " + e.FSM.Current()) 32 | }, 33 | "finish": func(_ context.Context, e *fsm.Event) { 34 | fmt.Println("finish: " + e.FSM.Current()) 35 | }, 36 | }, 37 | ) 38 | 39 | fmt.Println(fsm.Current()) 40 | 41 | err := fsm.Event(context.Background(), "scan") 42 | if err != nil { 43 | fmt.Println(err) 44 | } 45 | 46 | fmt.Println("1:" + fsm.Current()) 47 | 48 | err = fsm.Event(context.Background(), "working") 49 | if err != nil { 50 | fmt.Println(err) 51 | } 52 | 53 | fmt.Println("2:" + fsm.Current()) 54 | 55 | err = fsm.Event(context.Background(), "situation") 56 | if err != nil { 57 | fmt.Println(err) 58 | } 59 | 60 | fmt.Println("3:" + fsm.Current()) 61 | 62 | err = fsm.Event(context.Background(), "finish") 63 | if err != nil { 64 | fmt.Println(err) 65 | } 66 | 67 | fmt.Println("4:" + fsm.Current()) 68 | 69 | } 70 | -------------------------------------------------------------------------------- /examples/cancel_async_transition.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | // +build ignore 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "time" 10 | 11 | "github.com/looplab/fsm" 12 | ) 13 | 14 | func main() { 15 | f := fsm.NewFSM( 16 | "start", 17 | fsm.Events{ 18 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 19 | }, 20 | fsm.Callbacks{ 21 | "leave_start": func(_ context.Context, e *fsm.Event) { 22 | e.Async() 23 | }, 24 | }, 25 | ) 26 | 27 | err := f.Event(context.Background(), "run") 28 | asyncError, ok := err.(fsm.AsyncError) 29 | if !ok { 30 | panic(fmt.Sprintf("expected error to be 'AsyncError', got %v", err)) 31 | } 32 | var asyncStateTransitionWasCanceled bool 33 | go func() { 34 | <-asyncError.Ctx.Done() 35 | asyncStateTransitionWasCanceled = true 36 | if asyncError.Ctx.Err() != context.Canceled { 37 | panic(fmt.Sprintf("Expected error to be '%v' but was '%v'", context.Canceled, asyncError.Ctx.Err())) 38 | } 39 | }() 40 | asyncError.CancelTransition() 41 | time.Sleep(20 * time.Millisecond) 42 | 43 | if err = f.Transition(); err != nil { 44 | panic(fmt.Sprintf("Error encountered when transitioning: %v", err)) 45 | } 46 | if !asyncStateTransitionWasCanceled { 47 | panic("expected async state transition cancelation to have propagated") 48 | } 49 | if f.Current() != "start" { 50 | panic("expected state to be 'start'") 51 | } 52 | 53 | fmt.Println("Successfully ran state machine.") 54 | } 55 | -------------------------------------------------------------------------------- /examples/data.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | // +build ignore 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | 10 | "github.com/looplab/fsm" 11 | ) 12 | 13 | func main() { 14 | fsm := fsm.NewFSM( 15 | "idle", 16 | fsm.Events{ 17 | {Name: "produce", Src: []string{"idle"}, Dst: "idle"}, 18 | {Name: "consume", Src: []string{"idle"}, Dst: "idle"}, 19 | {Name: "remove", Src: []string{"idle"}, Dst: "idle"}, 20 | }, 21 | fsm.Callbacks{ 22 | "produce": func(_ context.Context, e *fsm.Event) { 23 | e.FSM.SetMetadata("message", "hii") 24 | fmt.Println("produced data") 25 | }, 26 | "consume": func(_ context.Context, e *fsm.Event) { 27 | message, ok := e.FSM.Metadata("message") 28 | if ok { 29 | fmt.Println("message = " + message.(string)) 30 | } 31 | }, 32 | "remove": func(_ context.Context, e *fsm.Event) { 33 | e.FSM.DeleteMetadata("message") 34 | if _, ok := e.FSM.Metadata("message"); !ok { 35 | fmt.Println("message removed") 36 | } 37 | }, 38 | }, 39 | ) 40 | 41 | fmt.Println(fsm.Current()) 42 | 43 | err := fsm.Event(context.Background(), "produce") 44 | if err != nil { 45 | fmt.Println(err) 46 | } 47 | 48 | fmt.Println(fsm.Current()) 49 | 50 | err = fsm.Event(context.Background(), "consume") 51 | if err != nil { 52 | fmt.Println(err) 53 | } 54 | 55 | fmt.Println(fsm.Current()) 56 | 57 | err = fsm.Event(context.Background(), "remove") 58 | if err != nil { 59 | fmt.Println(err) 60 | } 61 | 62 | fmt.Println(fsm.Current()) 63 | 64 | } 65 | -------------------------------------------------------------------------------- /examples/simple.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | // +build ignore 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | 10 | "github.com/looplab/fsm" 11 | ) 12 | 13 | func main() { 14 | fsm := fsm.NewFSM( 15 | "closed", 16 | fsm.Events{ 17 | {Name: "open", Src: []string{"closed"}, Dst: "open"}, 18 | {Name: "close", Src: []string{"open"}, Dst: "closed"}, 19 | }, 20 | fsm.Callbacks{}, 21 | ) 22 | 23 | fmt.Println(fsm.Current()) 24 | 25 | err := fsm.Event(context.Background(), "open") 26 | if err != nil { 27 | fmt.Println(err) 28 | } 29 | 30 | fmt.Println(fsm.Current()) 31 | 32 | err = fsm.Event(context.Background(), "close") 33 | if err != nil { 34 | fmt.Println(err) 35 | } 36 | 37 | fmt.Println(fsm.Current()) 38 | } 39 | -------------------------------------------------------------------------------- /examples/struct.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | // +build ignore 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | 10 | "github.com/looplab/fsm" 11 | ) 12 | 13 | type Door struct { 14 | To string 15 | FSM *fsm.FSM 16 | } 17 | 18 | func NewDoor(to string) *Door { 19 | d := &Door{ 20 | To: to, 21 | } 22 | 23 | d.FSM = fsm.NewFSM( 24 | "closed", 25 | fsm.Events{ 26 | {Name: "open", Src: []string{"closed"}, Dst: "open"}, 27 | {Name: "close", Src: []string{"open"}, Dst: "closed"}, 28 | }, 29 | fsm.Callbacks{ 30 | "enter_state": func(_ context.Context, e *fsm.Event) { d.enterState(e) }, 31 | }, 32 | ) 33 | 34 | return d 35 | } 36 | 37 | func (d *Door) enterState(e *fsm.Event) { 38 | fmt.Printf("The door to %s is %s\n", d.To, e.Dst) 39 | } 40 | 41 | func main() { 42 | door := NewDoor("heaven") 43 | 44 | err := door.FSM.Event(context.Background(), "open") 45 | if err != nil { 46 | fmt.Println(err) 47 | } 48 | 49 | err = door.FSM.Event(context.Background(), "close") 50 | if err != nil { 51 | fmt.Println(err) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /examples/transition_callbacks.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | // +build ignore 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | 10 | "github.com/looplab/fsm" 11 | ) 12 | 13 | func main() { 14 | var afterFinishCalled bool 15 | fsm := fsm.NewFSM( 16 | "start", 17 | fsm.Events{ 18 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 19 | {Name: "finish", Src: []string{"end"}, Dst: "finished"}, 20 | {Name: "reset", Src: []string{"end", "finished"}, Dst: "start"}, 21 | }, 22 | fsm.Callbacks{ 23 | "enter_end": func(ctx context.Context, e *fsm.Event) { 24 | if err := e.FSM.Event(ctx, "finish"); err != nil { 25 | fmt.Println(err) 26 | } 27 | }, 28 | "after_finish": func(ctx context.Context, e *fsm.Event) { 29 | afterFinishCalled = true 30 | if e.Src != "end" { 31 | panic(fmt.Sprintf("source should have been 'end' but was '%s'", e.Src)) 32 | } 33 | if err := e.FSM.Event(ctx, "reset"); err != nil { 34 | fmt.Println(err) 35 | } 36 | }, 37 | }, 38 | ) 39 | 40 | if err := fsm.Event(context.Background(), "run"); err != nil { 41 | panic(fmt.Sprintf("Error encountered when triggering the run event: %v", err)) 42 | } 43 | 44 | if !afterFinishCalled { 45 | panic(fmt.Sprintf("After finish callback should have run, current state: '%s'", fsm.Current())) 46 | } 47 | 48 | currentState := fsm.Current() 49 | if currentState != "start" { 50 | panic(fmt.Sprintf("expected state to be 'start', was '%s'", currentState)) 51 | } 52 | 53 | fmt.Println("Successfully ran state machine.") 54 | } 55 | -------------------------------------------------------------------------------- /fsm.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013 - Max Persson 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package fsm implements a finite state machine. 16 | // 17 | // It is heavily based on two FSM implementations: 18 | // 19 | // Javascript Finite State Machine 20 | // https://github.com/jakesgordon/javascript-state-machine 21 | // 22 | // Fysom for Python 23 | // https://github.com/oxplot/fysom (forked at https://github.com/mriehl/fysom) 24 | package fsm 25 | 26 | import ( 27 | "context" 28 | "strings" 29 | "sync" 30 | ) 31 | 32 | // transitioner is an interface for the FSM's transition function. 33 | type transitioner interface { 34 | transition(*FSM) error 35 | } 36 | 37 | // FSM is the state machine that holds the current state. 38 | // 39 | // It has to be created with NewFSM to function properly. 40 | type FSM struct { 41 | // current is the state that the FSM is currently in. 42 | current string 43 | 44 | // transitions maps events and source states to destination states. 45 | transitions map[eKey]string 46 | 47 | // callbacks maps events and targets to callback functions. 48 | callbacks map[cKey]Callback 49 | 50 | // transition is the internal transition functions used either directly 51 | // or when Transition is called in an asynchronous state transition. 52 | transition func() 53 | // transitionerObj calls the FSM's transition() function. 54 | transitionerObj transitioner 55 | 56 | // stateMu guards access to the current state. 57 | stateMu sync.RWMutex 58 | // eventMu guards access to Event() and Transition(). 59 | eventMu sync.Mutex 60 | // metadata can be used to store and load data that maybe used across events 61 | // use methods SetMetadata() and Metadata() to store and load data 62 | metadata map[string]interface{} 63 | 64 | metadataMu sync.RWMutex 65 | } 66 | 67 | // EventDesc represents an event when initializing the FSM. 68 | // 69 | // The event can have one or more source states that is valid for performing 70 | // the transition. If the FSM is in one of the source states it will end up in 71 | // the specified destination state, calling all defined callbacks as it goes. 72 | type EventDesc struct { 73 | // Name is the event name used when calling for a transition. 74 | Name string 75 | 76 | // Src is a slice of source states that the FSM must be in to perform a 77 | // state transition. 78 | Src []string 79 | 80 | // Dst is the destination state that the FSM will be in if the transition 81 | // succeeds. 82 | Dst string 83 | } 84 | 85 | // Callback is a function type that callbacks should use. Event is the current 86 | // event info as the callback happens. 87 | type Callback func(context.Context, *Event) 88 | 89 | // Events is a shorthand for defining the transition map in NewFSM. 90 | type Events []EventDesc 91 | 92 | // Callbacks is a shorthand for defining the callbacks in NewFSM. 93 | type Callbacks map[string]Callback 94 | 95 | // NewFSM constructs a FSM from events and callbacks. 96 | // 97 | // The events and transitions are specified as a slice of Event structs 98 | // specified as Events. Each Event is mapped to one or more internal 99 | // transitions from Event.Src to Event.Dst. 100 | // 101 | // Callbacks are added as a map specified as Callbacks where the key is parsed 102 | // as the callback event as follows, and called in the same order: 103 | // 104 | // 1. before_ - called before event named 105 | // 106 | // 2. before_event - called before all events 107 | // 108 | // 3. leave_ - called before leaving 109 | // 110 | // 4. leave_state - called before leaving all states 111 | // 112 | // 5. enter_ - called after entering 113 | // 114 | // 6. enter_state - called after entering all states 115 | // 116 | // 7. after_ - called after event named 117 | // 118 | // 8. after_event - called after all events 119 | // 120 | // There are also two short form versions for the most commonly used callbacks. 121 | // They are simply the name of the event or state: 122 | // 123 | // 1. - called after entering 124 | // 125 | // 2. - called after event named 126 | // 127 | // If both a shorthand version and a full version is specified it is undefined 128 | // which version of the callback will end up in the internal map. This is due 129 | // to the pseudo random nature of Go maps. No checking for multiple keys is 130 | // currently performed. 131 | func NewFSM(initial string, events []EventDesc, callbacks map[string]Callback) *FSM { 132 | f := &FSM{ 133 | transitionerObj: &transitionerStruct{}, 134 | current: initial, 135 | transitions: make(map[eKey]string), 136 | callbacks: make(map[cKey]Callback), 137 | metadata: make(map[string]interface{}), 138 | } 139 | 140 | // Build transition map and store sets of all events and states. 141 | allEvents := make(map[string]bool) 142 | allStates := make(map[string]bool) 143 | for _, e := range events { 144 | for _, src := range e.Src { 145 | f.transitions[eKey{e.Name, src}] = e.Dst 146 | allStates[src] = true 147 | allStates[e.Dst] = true 148 | } 149 | allEvents[e.Name] = true 150 | } 151 | 152 | // Map all callbacks to events/states. 153 | for name, fn := range callbacks { 154 | var target string 155 | var callbackType int 156 | 157 | switch { 158 | case strings.HasPrefix(name, "before_"): 159 | target = strings.TrimPrefix(name, "before_") 160 | if target == "event" { 161 | target = "" 162 | callbackType = callbackBeforeEvent 163 | } else if _, ok := allEvents[target]; ok { 164 | callbackType = callbackBeforeEvent 165 | } 166 | case strings.HasPrefix(name, "leave_"): 167 | target = strings.TrimPrefix(name, "leave_") 168 | if target == "state" { 169 | target = "" 170 | callbackType = callbackLeaveState 171 | } else if _, ok := allStates[target]; ok { 172 | callbackType = callbackLeaveState 173 | } 174 | case strings.HasPrefix(name, "enter_"): 175 | target = strings.TrimPrefix(name, "enter_") 176 | if target == "state" { 177 | target = "" 178 | callbackType = callbackEnterState 179 | } else if _, ok := allStates[target]; ok { 180 | callbackType = callbackEnterState 181 | } 182 | case strings.HasPrefix(name, "after_"): 183 | target = strings.TrimPrefix(name, "after_") 184 | if target == "event" { 185 | target = "" 186 | callbackType = callbackAfterEvent 187 | } else if _, ok := allEvents[target]; ok { 188 | callbackType = callbackAfterEvent 189 | } 190 | default: 191 | target = name 192 | if _, ok := allStates[target]; ok { 193 | callbackType = callbackEnterState 194 | } else if _, ok := allEvents[target]; ok { 195 | callbackType = callbackAfterEvent 196 | } 197 | } 198 | 199 | if callbackType != callbackNone { 200 | f.callbacks[cKey{target, callbackType}] = fn 201 | } 202 | } 203 | 204 | return f 205 | } 206 | 207 | // Current returns the current state of the FSM. 208 | func (f *FSM) Current() string { 209 | f.stateMu.RLock() 210 | defer f.stateMu.RUnlock() 211 | return f.current 212 | } 213 | 214 | // Is returns true if state is the current state. 215 | func (f *FSM) Is(state string) bool { 216 | f.stateMu.RLock() 217 | defer f.stateMu.RUnlock() 218 | return state == f.current 219 | } 220 | 221 | // SetState allows the user to move to the given state from current state. 222 | // The call does not trigger any callbacks, if defined. 223 | func (f *FSM) SetState(state string) { 224 | f.stateMu.Lock() 225 | defer f.stateMu.Unlock() 226 | f.current = state 227 | } 228 | 229 | // Can returns true if event can occur in the current state. 230 | func (f *FSM) Can(event string) bool { 231 | f.eventMu.Lock() 232 | defer f.eventMu.Unlock() 233 | f.stateMu.RLock() 234 | defer f.stateMu.RUnlock() 235 | _, ok := f.transitions[eKey{event, f.current}] 236 | return ok && (f.transition == nil) 237 | } 238 | 239 | // AvailableTransitions returns a list of transitions available in the 240 | // current state. 241 | func (f *FSM) AvailableTransitions() []string { 242 | f.stateMu.RLock() 243 | defer f.stateMu.RUnlock() 244 | var transitions []string 245 | for key := range f.transitions { 246 | if key.src == f.current { 247 | transitions = append(transitions, key.event) 248 | } 249 | } 250 | return transitions 251 | } 252 | 253 | // Cannot returns true if event can not occur in the current state. 254 | // It is a convenience method to help code read nicely. 255 | func (f *FSM) Cannot(event string) bool { 256 | return !f.Can(event) 257 | } 258 | 259 | // Metadata returns the value stored in metadata 260 | func (f *FSM) Metadata(key string) (interface{}, bool) { 261 | f.metadataMu.RLock() 262 | defer f.metadataMu.RUnlock() 263 | dataElement, ok := f.metadata[key] 264 | return dataElement, ok 265 | } 266 | 267 | // SetMetadata stores the dataValue in metadata indexing it with key 268 | func (f *FSM) SetMetadata(key string, dataValue interface{}) { 269 | f.metadataMu.Lock() 270 | defer f.metadataMu.Unlock() 271 | f.metadata[key] = dataValue 272 | } 273 | 274 | // DeleteMetadata deletes the dataValue in metadata by key 275 | func (f *FSM) DeleteMetadata(key string) { 276 | f.metadataMu.Lock() 277 | delete(f.metadata, key) 278 | f.metadataMu.Unlock() 279 | } 280 | 281 | // Event initiates a state transition with the named event. 282 | // 283 | // The call takes a variable number of arguments that will be passed to the 284 | // callback, if defined. 285 | // 286 | // It will return nil if the state change is ok or one of these errors: 287 | // 288 | // - event X inappropriate because previous transition did not complete 289 | // 290 | // - event X inappropriate in current state Y 291 | // 292 | // - event X does not exist 293 | // 294 | // - internal error on state transition 295 | // 296 | // The last error should never occur in this situation and is a sign of an 297 | // internal bug. 298 | func (f *FSM) Event(ctx context.Context, event string, args ...interface{}) error { 299 | f.eventMu.Lock() 300 | // in order to always unlock the event mutex, the defer is added 301 | // in case the state transition goes through and enter/after callbacks 302 | // are called; because these must be able to trigger new state 303 | // transitions, it is explicitly unlocked in the code below 304 | var unlocked bool 305 | defer func() { 306 | if !unlocked { 307 | f.eventMu.Unlock() 308 | } 309 | }() 310 | 311 | f.stateMu.RLock() 312 | defer f.stateMu.RUnlock() 313 | 314 | if f.transition != nil { 315 | return InTransitionError{event} 316 | } 317 | 318 | dst, ok := f.transitions[eKey{event, f.current}] 319 | if !ok { 320 | for ekey := range f.transitions { 321 | if ekey.event == event { 322 | return InvalidEventError{event, f.current} 323 | } 324 | } 325 | return UnknownEventError{event} 326 | } 327 | 328 | ctx, cancel := context.WithCancel(ctx) 329 | defer cancel() 330 | e := &Event{f, event, f.current, dst, nil, args, false, false, cancel} 331 | 332 | err := f.beforeEventCallbacks(ctx, e) 333 | if err != nil { 334 | return err 335 | } 336 | 337 | if f.current == dst { 338 | f.stateMu.RUnlock() 339 | defer f.stateMu.RLock() 340 | f.eventMu.Unlock() 341 | unlocked = true 342 | f.afterEventCallbacks(ctx, e) 343 | return NoTransitionError{e.Err} 344 | } 345 | 346 | // Setup the transition, call it later. 347 | transitionFunc := func(ctx context.Context, async bool) func() { 348 | return func() { 349 | if ctx.Err() != nil { 350 | if e.Err == nil { 351 | e.Err = ctx.Err() 352 | } 353 | return 354 | } 355 | 356 | f.stateMu.Lock() 357 | f.current = dst 358 | f.transition = nil // treat the state transition as done 359 | f.stateMu.Unlock() 360 | 361 | // at this point, we unlock the event mutex in order to allow 362 | // enter state callbacks to trigger another transition 363 | // for aynchronous state transitions this doesn't happen because 364 | // the event mutex has already been unlocked 365 | if !async { 366 | f.eventMu.Unlock() 367 | unlocked = true 368 | } 369 | f.enterStateCallbacks(ctx, e) 370 | f.afterEventCallbacks(ctx, e) 371 | } 372 | } 373 | 374 | f.transition = transitionFunc(ctx, false) 375 | 376 | if err = f.leaveStateCallbacks(ctx, e); err != nil { 377 | if _, ok := err.(CanceledError); ok { 378 | f.transition = nil 379 | } else if asyncError, ok := err.(AsyncError); ok { 380 | // setup a new context in order for async state transitions to work correctly 381 | // this "uncancels" the original context which ignores its cancelation 382 | // but keeps the values of the original context available to callers 383 | ctx, cancel := uncancelContext(ctx) 384 | e.cancelFunc = cancel 385 | asyncError.Ctx = ctx 386 | asyncError.CancelTransition = cancel 387 | f.transition = transitionFunc(ctx, true) 388 | return asyncError 389 | } 390 | return err 391 | } 392 | 393 | // Perform the rest of the transition, if not asynchronous. 394 | f.stateMu.RUnlock() 395 | defer f.stateMu.RLock() 396 | err = f.doTransition() 397 | if err != nil { 398 | return InternalError{} 399 | } 400 | 401 | return e.Err 402 | } 403 | 404 | // Transition wraps transitioner.transition. 405 | func (f *FSM) Transition() error { 406 | f.eventMu.Lock() 407 | defer f.eventMu.Unlock() 408 | return f.doTransition() 409 | } 410 | 411 | // doTransition wraps transitioner.transition. 412 | func (f *FSM) doTransition() error { 413 | return f.transitionerObj.transition(f) 414 | } 415 | 416 | // transitionerStruct is the default implementation of the transitioner 417 | // interface. Other implementations can be swapped in for testing. 418 | type transitionerStruct struct{} 419 | 420 | // Transition completes an asynchronous state change. 421 | // 422 | // The callback for leave_ must previously have called Async on its 423 | // event to have initiated an asynchronous state transition. 424 | func (t transitionerStruct) transition(f *FSM) error { 425 | if f.transition == nil { 426 | return NotInTransitionError{} 427 | } 428 | f.transition() 429 | return nil 430 | } 431 | 432 | // beforeEventCallbacks calls the before_ callbacks, first the named then the 433 | // general version. 434 | func (f *FSM) beforeEventCallbacks(ctx context.Context, e *Event) error { 435 | if fn, ok := f.callbacks[cKey{e.Event, callbackBeforeEvent}]; ok { 436 | fn(ctx, e) 437 | if e.canceled { 438 | return CanceledError{e.Err} 439 | } 440 | } 441 | if fn, ok := f.callbacks[cKey{"", callbackBeforeEvent}]; ok { 442 | fn(ctx, e) 443 | if e.canceled { 444 | return CanceledError{e.Err} 445 | } 446 | } 447 | return nil 448 | } 449 | 450 | // leaveStateCallbacks calls the leave_ callbacks, first the named then the 451 | // general version. 452 | func (f *FSM) leaveStateCallbacks(ctx context.Context, e *Event) error { 453 | if fn, ok := f.callbacks[cKey{f.current, callbackLeaveState}]; ok { 454 | fn(ctx, e) 455 | if e.canceled { 456 | return CanceledError{e.Err} 457 | } else if e.async { 458 | return AsyncError{Err: e.Err} 459 | } 460 | } 461 | if fn, ok := f.callbacks[cKey{"", callbackLeaveState}]; ok { 462 | fn(ctx, e) 463 | if e.canceled { 464 | return CanceledError{e.Err} 465 | } else if e.async { 466 | return AsyncError{Err: e.Err} 467 | } 468 | } 469 | return nil 470 | } 471 | 472 | // enterStateCallbacks calls the enter_ callbacks, first the named then the 473 | // general version. 474 | func (f *FSM) enterStateCallbacks(ctx context.Context, e *Event) { 475 | if fn, ok := f.callbacks[cKey{f.current, callbackEnterState}]; ok { 476 | fn(ctx, e) 477 | } 478 | if fn, ok := f.callbacks[cKey{"", callbackEnterState}]; ok { 479 | fn(ctx, e) 480 | } 481 | } 482 | 483 | // afterEventCallbacks calls the after_ callbacks, first the named then the 484 | // general version. 485 | func (f *FSM) afterEventCallbacks(ctx context.Context, e *Event) { 486 | if fn, ok := f.callbacks[cKey{e.Event, callbackAfterEvent}]; ok { 487 | fn(ctx, e) 488 | } 489 | if fn, ok := f.callbacks[cKey{"", callbackAfterEvent}]; ok { 490 | fn(ctx, e) 491 | } 492 | } 493 | 494 | const ( 495 | callbackNone int = iota 496 | callbackBeforeEvent 497 | callbackLeaveState 498 | callbackEnterState 499 | callbackAfterEvent 500 | ) 501 | 502 | // cKey is a struct key used for keeping the callbacks mapped to a target. 503 | type cKey struct { 504 | // target is either the name of a state or an event depending on which 505 | // callback type the key refers to. It can also be "" for a non-targeted 506 | // callback like before_event. 507 | target string 508 | 509 | // callbackType is the situation when the callback will be run. 510 | callbackType int 511 | } 512 | 513 | // eKey is a struct key used for storing the transition map. 514 | type eKey struct { 515 | // event is the name of the event that the keys refers to. 516 | event string 517 | 518 | // src is the source from where the event can transition. 519 | src string 520 | } 521 | -------------------------------------------------------------------------------- /fsm_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013 - Max Persson 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package fsm 16 | 17 | import ( 18 | "context" 19 | "errors" 20 | "fmt" 21 | "sort" 22 | "sync" 23 | "testing" 24 | "time" 25 | ) 26 | 27 | type fakeTransitionerObj struct { 28 | } 29 | 30 | func (t fakeTransitionerObj) transition(f *FSM) error { 31 | return &InternalError{} 32 | } 33 | 34 | func TestSameState(t *testing.T) { 35 | fsm := NewFSM( 36 | "start", 37 | Events{ 38 | {Name: "run", Src: []string{"start"}, Dst: "start"}, 39 | }, 40 | Callbacks{}, 41 | ) 42 | _ = fsm.Event(context.Background(), "run") 43 | if fsm.Current() != "start" { 44 | t.Error("expected state to be 'start'") 45 | } 46 | } 47 | 48 | func TestSetState(t *testing.T) { 49 | fsm := NewFSM( 50 | "walking", 51 | Events{ 52 | {Name: "walk", Src: []string{"start"}, Dst: "walking"}, 53 | }, 54 | Callbacks{}, 55 | ) 56 | fsm.SetState("start") 57 | if fsm.Current() != "start" { 58 | t.Error("expected state to be 'walking'") 59 | } 60 | err := fsm.Event(context.Background(), "walk") 61 | if err != nil { 62 | t.Error("transition is expected no error") 63 | } 64 | } 65 | 66 | func TestBadTransition(t *testing.T) { 67 | fsm := NewFSM( 68 | "start", 69 | Events{ 70 | {Name: "run", Src: []string{"start"}, Dst: "running"}, 71 | }, 72 | Callbacks{}, 73 | ) 74 | fsm.transitionerObj = new(fakeTransitionerObj) 75 | err := fsm.Event(context.Background(), "run") 76 | if err == nil { 77 | t.Error("bad transition should give an error") 78 | } 79 | } 80 | 81 | func TestInappropriateEvent(t *testing.T) { 82 | fsm := NewFSM( 83 | "closed", 84 | Events{ 85 | {Name: "open", Src: []string{"closed"}, Dst: "open"}, 86 | {Name: "close", Src: []string{"open"}, Dst: "closed"}, 87 | }, 88 | Callbacks{}, 89 | ) 90 | err := fsm.Event(context.Background(), "close") 91 | if e, ok := err.(InvalidEventError); !ok && e.Event != "close" && e.State != "closed" { 92 | t.Error("expected 'InvalidEventError' with correct state and event") 93 | } 94 | } 95 | 96 | func TestInvalidEvent(t *testing.T) { 97 | fsm := NewFSM( 98 | "closed", 99 | Events{ 100 | {Name: "open", Src: []string{"closed"}, Dst: "open"}, 101 | {Name: "close", Src: []string{"open"}, Dst: "closed"}, 102 | }, 103 | Callbacks{}, 104 | ) 105 | err := fsm.Event(context.Background(), "lock") 106 | if e, ok := err.(UnknownEventError); !ok && e.Event != "close" { 107 | t.Error("expected 'UnknownEventError' with correct event") 108 | } 109 | } 110 | 111 | func TestMultipleSources(t *testing.T) { 112 | fsm := NewFSM( 113 | "one", 114 | Events{ 115 | {Name: "first", Src: []string{"one"}, Dst: "two"}, 116 | {Name: "second", Src: []string{"two"}, Dst: "three"}, 117 | {Name: "reset", Src: []string{"one", "two", "three"}, Dst: "one"}, 118 | }, 119 | Callbacks{}, 120 | ) 121 | 122 | err := fsm.Event(context.Background(), "first") 123 | if err != nil { 124 | t.Errorf("transition failed %v", err) 125 | } 126 | if fsm.Current() != "two" { 127 | t.Error("expected state to be 'two'") 128 | } 129 | err = fsm.Event(context.Background(), "reset") 130 | if err != nil { 131 | t.Errorf("transition failed %v", err) 132 | } 133 | if fsm.Current() != "one" { 134 | t.Error("expected state to be 'one'") 135 | } 136 | err = fsm.Event(context.Background(), "first") 137 | if err != nil { 138 | t.Errorf("transition failed %v", err) 139 | } 140 | err = fsm.Event(context.Background(), "second") 141 | if err != nil { 142 | t.Errorf("transition failed %v", err) 143 | } 144 | if fsm.Current() != "three" { 145 | t.Error("expected state to be 'three'") 146 | } 147 | err = fsm.Event(context.Background(), "reset") 148 | if err != nil { 149 | t.Errorf("transition failed %v", err) 150 | } 151 | if fsm.Current() != "one" { 152 | t.Error("expected state to be 'one'") 153 | } 154 | } 155 | 156 | func TestMultipleEvents(t *testing.T) { 157 | fsm := NewFSM( 158 | "start", 159 | Events{ 160 | {Name: "first", Src: []string{"start"}, Dst: "one"}, 161 | {Name: "second", Src: []string{"start"}, Dst: "two"}, 162 | {Name: "reset", Src: []string{"one"}, Dst: "reset_one"}, 163 | {Name: "reset", Src: []string{"two"}, Dst: "reset_two"}, 164 | {Name: "reset", Src: []string{"reset_one", "reset_two"}, Dst: "start"}, 165 | }, 166 | Callbacks{}, 167 | ) 168 | 169 | err := fsm.Event(context.Background(), "first") 170 | if err != nil { 171 | t.Errorf("transition failed %v", err) 172 | } 173 | err = fsm.Event(context.Background(), "reset") 174 | if err != nil { 175 | t.Errorf("transition failed %v", err) 176 | } 177 | if fsm.Current() != "reset_one" { 178 | t.Error("expected state to be 'reset_one'") 179 | } 180 | err = fsm.Event(context.Background(), "reset") 181 | if err != nil { 182 | t.Errorf("transition failed %v", err) 183 | } 184 | if fsm.Current() != "start" { 185 | t.Error("expected state to be 'start'") 186 | } 187 | 188 | err = fsm.Event(context.Background(), "second") 189 | if err != nil { 190 | t.Errorf("transition failed %v", err) 191 | } 192 | err = fsm.Event(context.Background(), "reset") 193 | if err != nil { 194 | t.Errorf("transition failed %v", err) 195 | } 196 | if fsm.Current() != "reset_two" { 197 | t.Error("expected state to be 'reset_two'") 198 | } 199 | err = fsm.Event(context.Background(), "reset") 200 | if err != nil { 201 | t.Errorf("transition failed %v", err) 202 | } 203 | if fsm.Current() != "start" { 204 | t.Error("expected state to be 'start'") 205 | } 206 | } 207 | 208 | func TestGenericCallbacks(t *testing.T) { 209 | beforeEvent := false 210 | leaveState := false 211 | enterState := false 212 | afterEvent := false 213 | 214 | fsm := NewFSM( 215 | "start", 216 | Events{ 217 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 218 | }, 219 | Callbacks{ 220 | "before_event": func(_ context.Context, e *Event) { 221 | beforeEvent = true 222 | }, 223 | "leave_state": func(_ context.Context, e *Event) { 224 | leaveState = true 225 | }, 226 | "enter_state": func(_ context.Context, e *Event) { 227 | enterState = true 228 | }, 229 | "after_event": func(_ context.Context, e *Event) { 230 | afterEvent = true 231 | }, 232 | }, 233 | ) 234 | 235 | err := fsm.Event(context.Background(), "run") 236 | if err != nil { 237 | t.Errorf("transition failed %v", err) 238 | } 239 | if !(beforeEvent && leaveState && enterState && afterEvent) { 240 | t.Error("expected all callbacks to be called") 241 | } 242 | } 243 | 244 | func TestSpecificCallbacks(t *testing.T) { 245 | beforeEvent := false 246 | leaveState := false 247 | enterState := false 248 | afterEvent := false 249 | 250 | fsm := NewFSM( 251 | "start", 252 | Events{ 253 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 254 | }, 255 | Callbacks{ 256 | "before_run": func(_ context.Context, e *Event) { 257 | beforeEvent = true 258 | }, 259 | "leave_start": func(_ context.Context, e *Event) { 260 | leaveState = true 261 | }, 262 | "enter_end": func(_ context.Context, e *Event) { 263 | enterState = true 264 | }, 265 | "after_run": func(_ context.Context, e *Event) { 266 | afterEvent = true 267 | }, 268 | }, 269 | ) 270 | 271 | err := fsm.Event(context.Background(), "run") 272 | if err != nil { 273 | t.Errorf("transition failed %v", err) 274 | } 275 | if !(beforeEvent && leaveState && enterState && afterEvent) { 276 | t.Error("expected all callbacks to be called") 277 | } 278 | } 279 | 280 | func TestSpecificCallbacksShortform(t *testing.T) { 281 | enterState := false 282 | afterEvent := false 283 | 284 | fsm := NewFSM( 285 | "start", 286 | Events{ 287 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 288 | }, 289 | Callbacks{ 290 | "end": func(_ context.Context, e *Event) { 291 | enterState = true 292 | }, 293 | "run": func(_ context.Context, e *Event) { 294 | afterEvent = true 295 | }, 296 | }, 297 | ) 298 | 299 | err := fsm.Event(context.Background(), "run") 300 | if err != nil { 301 | t.Errorf("transition failed %v", err) 302 | } 303 | if !(enterState && afterEvent) { 304 | t.Error("expected all callbacks to be called") 305 | } 306 | } 307 | 308 | func TestBeforeEventWithoutTransition(t *testing.T) { 309 | beforeEvent := true 310 | 311 | fsm := NewFSM( 312 | "start", 313 | Events{ 314 | {Name: "dontrun", Src: []string{"start"}, Dst: "start"}, 315 | }, 316 | Callbacks{ 317 | "before_event": func(_ context.Context, e *Event) { 318 | beforeEvent = true 319 | }, 320 | }, 321 | ) 322 | 323 | err := fsm.Event(context.Background(), "dontrun") 324 | if e, ok := err.(NoTransitionError); !ok && e.Err != nil { 325 | t.Error("expected 'NoTransitionError' without custom error") 326 | } 327 | 328 | if fsm.Current() != "start" { 329 | t.Error("expected state to be 'start'") 330 | } 331 | if !beforeEvent { 332 | t.Error("expected callback to be called") 333 | } 334 | } 335 | 336 | func TestCancelBeforeGenericEvent(t *testing.T) { 337 | fsm := NewFSM( 338 | "start", 339 | Events{ 340 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 341 | }, 342 | Callbacks{ 343 | "before_event": func(_ context.Context, e *Event) { 344 | e.Cancel() 345 | }, 346 | }, 347 | ) 348 | _ = fsm.Event(context.Background(), "run") 349 | if fsm.Current() != "start" { 350 | t.Error("expected state to be 'start'") 351 | } 352 | } 353 | 354 | func TestCancelBeforeSpecificEvent(t *testing.T) { 355 | fsm := NewFSM( 356 | "start", 357 | Events{ 358 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 359 | }, 360 | Callbacks{ 361 | "before_run": func(_ context.Context, e *Event) { 362 | e.Cancel() 363 | }, 364 | }, 365 | ) 366 | _ = fsm.Event(context.Background(), "run") 367 | if fsm.Current() != "start" { 368 | t.Error("expected state to be 'start'") 369 | } 370 | } 371 | 372 | func TestCancelLeaveGenericState(t *testing.T) { 373 | fsm := NewFSM( 374 | "start", 375 | Events{ 376 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 377 | }, 378 | Callbacks{ 379 | "leave_state": func(_ context.Context, e *Event) { 380 | e.Cancel() 381 | }, 382 | }, 383 | ) 384 | _ = fsm.Event(context.Background(), "run") 385 | if fsm.Current() != "start" { 386 | t.Error("expected state to be 'start'") 387 | } 388 | } 389 | 390 | func TestCancelLeaveSpecificState(t *testing.T) { 391 | fsm := NewFSM( 392 | "start", 393 | Events{ 394 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 395 | }, 396 | Callbacks{ 397 | "leave_start": func(_ context.Context, e *Event) { 398 | e.Cancel() 399 | }, 400 | }, 401 | ) 402 | _ = fsm.Event(context.Background(), "run") 403 | if fsm.Current() != "start" { 404 | t.Error("expected state to be 'start'") 405 | } 406 | } 407 | 408 | func TestCancelWithError(t *testing.T) { 409 | fsm := NewFSM( 410 | "start", 411 | Events{ 412 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 413 | }, 414 | Callbacks{ 415 | "before_event": func(_ context.Context, e *Event) { 416 | e.Cancel(fmt.Errorf("error")) 417 | }, 418 | }, 419 | ) 420 | err := fsm.Event(context.Background(), "run") 421 | if _, ok := err.(CanceledError); !ok { 422 | t.Error("expected only 'CanceledError'") 423 | } 424 | 425 | if e, ok := err.(CanceledError); ok && e.Err.Error() != "error" { 426 | t.Error("expected 'CanceledError' with correct custom error") 427 | } 428 | 429 | if fsm.Current() != "start" { 430 | t.Error("expected state to be 'start'") 431 | } 432 | } 433 | 434 | func TestAsyncTransitionGenericState(t *testing.T) { 435 | fsm := NewFSM( 436 | "start", 437 | Events{ 438 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 439 | }, 440 | Callbacks{ 441 | "leave_state": func(_ context.Context, e *Event) { 442 | e.Async() 443 | }, 444 | }, 445 | ) 446 | _ = fsm.Event(context.Background(), "run") 447 | if fsm.Current() != "start" { 448 | t.Error("expected state to be 'start'") 449 | } 450 | err := fsm.Transition() 451 | if err != nil { 452 | t.Errorf("transition failed %v", err) 453 | } 454 | if fsm.Current() != "end" { 455 | t.Error("expected state to be 'end'") 456 | } 457 | } 458 | 459 | func TestAsyncTransitionSpecificState(t *testing.T) { 460 | fsm := NewFSM( 461 | "start", 462 | Events{ 463 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 464 | }, 465 | Callbacks{ 466 | "leave_start": func(_ context.Context, e *Event) { 467 | e.Async() 468 | }, 469 | }, 470 | ) 471 | _ = fsm.Event(context.Background(), "run") 472 | if fsm.Current() != "start" { 473 | t.Error("expected state to be 'start'") 474 | } 475 | err := fsm.Transition() 476 | if err != nil { 477 | t.Errorf("transition failed %v", err) 478 | } 479 | if fsm.Current() != "end" { 480 | t.Error("expected state to be 'end'") 481 | } 482 | } 483 | 484 | func TestAsyncTransitionInProgress(t *testing.T) { 485 | fsm := NewFSM( 486 | "start", 487 | Events{ 488 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 489 | {Name: "reset", Src: []string{"end"}, Dst: "start"}, 490 | }, 491 | Callbacks{ 492 | "leave_start": func(_ context.Context, e *Event) { 493 | e.Async() 494 | }, 495 | }, 496 | ) 497 | _ = fsm.Event(context.Background(), "run") 498 | err := fsm.Event(context.Background(), "reset") 499 | if e, ok := err.(InTransitionError); !ok && e.Event != "reset" { 500 | t.Error("expected 'InTransitionError' with correct state") 501 | } 502 | err = fsm.Transition() 503 | if err != nil { 504 | t.Errorf("transition failed %v", err) 505 | } 506 | err = fsm.Event(context.Background(), "reset") 507 | if err != nil { 508 | t.Errorf("transition failed %v", err) 509 | } 510 | if fsm.Current() != "start" { 511 | t.Error("expected state to be 'start'") 512 | } 513 | } 514 | 515 | func TestAsyncTransitionNotInProgress(t *testing.T) { 516 | fsm := NewFSM( 517 | "start", 518 | Events{ 519 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 520 | {Name: "reset", Src: []string{"end"}, Dst: "start"}, 521 | }, 522 | Callbacks{}, 523 | ) 524 | err := fsm.Transition() 525 | if _, ok := err.(NotInTransitionError); !ok { 526 | t.Error("expected 'NotInTransitionError'") 527 | } 528 | } 529 | 530 | func TestCancelAsyncTransition(t *testing.T) { 531 | fsm := NewFSM( 532 | "start", 533 | Events{ 534 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 535 | }, 536 | Callbacks{ 537 | "leave_start": func(_ context.Context, e *Event) { 538 | e.Async() 539 | }, 540 | }, 541 | ) 542 | err := fsm.Event(context.Background(), "run") 543 | asyncError, ok := err.(AsyncError) 544 | if !ok { 545 | t.Errorf("expected error to be 'AsyncError', got %v", err) 546 | } 547 | var asyncStateTransitionWasCanceled = make(chan struct{}) 548 | go func() { 549 | <-asyncError.Ctx.Done() 550 | close(asyncStateTransitionWasCanceled) 551 | }() 552 | asyncError.CancelTransition() 553 | <-asyncStateTransitionWasCanceled 554 | 555 | if err = fsm.Transition(); err != nil { 556 | t.Errorf("expected no error, got %v", err) 557 | } 558 | if fsm.Current() != "start" { 559 | t.Error("expected state to be 'start'") 560 | } 561 | } 562 | 563 | func TestCallbackNoError(t *testing.T) { 564 | fsm := NewFSM( 565 | "start", 566 | Events{ 567 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 568 | }, 569 | Callbacks{ 570 | "run": func(_ context.Context, e *Event) { 571 | }, 572 | }, 573 | ) 574 | e := fsm.Event(context.Background(), "run") 575 | if e != nil { 576 | t.Error("expected no error") 577 | } 578 | } 579 | 580 | func TestCallbackError(t *testing.T) { 581 | fsm := NewFSM( 582 | "start", 583 | Events{ 584 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 585 | }, 586 | Callbacks{ 587 | "run": func(_ context.Context, e *Event) { 588 | e.Err = fmt.Errorf("error") 589 | }, 590 | }, 591 | ) 592 | e := fsm.Event(context.Background(), "run") 593 | if e.Error() != "error" { 594 | t.Error("expected error to be 'error'") 595 | } 596 | } 597 | 598 | func TestCallbackArgs(t *testing.T) { 599 | fsm := NewFSM( 600 | "start", 601 | Events{ 602 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 603 | }, 604 | Callbacks{ 605 | "run": func(_ context.Context, e *Event) { 606 | if len(e.Args) != 1 { 607 | t.Error("too few arguments") 608 | } 609 | arg, ok := e.Args[0].(string) 610 | if !ok { 611 | t.Error("not a string argument") 612 | } 613 | if arg != "test" { 614 | t.Error("incorrect argument") 615 | } 616 | }, 617 | }, 618 | ) 619 | err := fsm.Event(context.Background(), "run", "test") 620 | if err != nil { 621 | t.Errorf("transition failed %v", err) 622 | } 623 | } 624 | 625 | func TestCallbackPanic(t *testing.T) { 626 | panicMsg := "unexpected panic" 627 | defer func() { 628 | r := recover() 629 | if r == nil || r != panicMsg { 630 | t.Errorf("expected panic message to be '%s', got %v", panicMsg, r) 631 | } 632 | }() 633 | fsm := NewFSM( 634 | "start", 635 | Events{ 636 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 637 | }, 638 | Callbacks{ 639 | "run": func(_ context.Context, e *Event) { 640 | panic(panicMsg) 641 | }, 642 | }, 643 | ) 644 | e := fsm.Event(context.Background(), "run") 645 | if e.Error() != "error" { 646 | t.Error("expected error to be 'error'") 647 | } 648 | } 649 | 650 | func TestNoDeadLock(t *testing.T) { 651 | var fsm *FSM 652 | fsm = NewFSM( 653 | "start", 654 | Events{ 655 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 656 | }, 657 | Callbacks{ 658 | "run": func(_ context.Context, e *Event) { 659 | fsm.Current() // Should not result in a panic / deadlock 660 | }, 661 | }, 662 | ) 663 | err := fsm.Event(context.Background(), "run") 664 | if err != nil { 665 | t.Errorf("transition failed %v", err) 666 | } 667 | } 668 | 669 | func TestThreadSafetyRaceCondition(t *testing.T) { 670 | fsm := NewFSM( 671 | "start", 672 | Events{ 673 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 674 | }, 675 | Callbacks{ 676 | "run": func(_ context.Context, e *Event) { 677 | }, 678 | }, 679 | ) 680 | var wg sync.WaitGroup 681 | wg.Add(1) 682 | go func() { 683 | defer wg.Done() 684 | _ = fsm.Current() 685 | }() 686 | err := fsm.Event(context.Background(), "run") 687 | if err != nil { 688 | t.Errorf("transition failed %v", err) 689 | } 690 | wg.Wait() 691 | } 692 | 693 | func TestDoubleTransition(t *testing.T) { 694 | var fsm *FSM 695 | var wg sync.WaitGroup 696 | wg.Add(2) 697 | fsm = NewFSM( 698 | "start", 699 | Events{ 700 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 701 | }, 702 | Callbacks{ 703 | "before_run": func(_ context.Context, e *Event) { 704 | wg.Done() 705 | // Imagine a concurrent event coming in of the same type while 706 | // the data access mutex is unlocked because the current transition 707 | // is running its event callbacks, getting around the "active" 708 | // transition checks 709 | if len(e.Args) == 0 { 710 | // Must be concurrent so the test may pass when we add a mutex that synchronizes 711 | // calls to Event(...). It will then fail as an inappropriate transition as we 712 | // have changed state. 713 | go func() { 714 | if err := fsm.Event(context.Background(), "run", "second run"); err != nil { 715 | fmt.Println(err) 716 | wg.Done() // It should fail, and then we unfreeze the test. 717 | } 718 | }() 719 | time.Sleep(20 * time.Millisecond) 720 | } else { 721 | panic("Was able to reissue an event mid-transition") 722 | } 723 | }, 724 | }, 725 | ) 726 | if err := fsm.Event(context.Background(), "run"); err != nil { 727 | fmt.Println(err) 728 | } 729 | wg.Wait() 730 | } 731 | 732 | func TestTransitionInCallbacks(t *testing.T) { 733 | var fsm *FSM 734 | var afterFinishCalled bool 735 | fsm = NewFSM( 736 | "start", 737 | Events{ 738 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 739 | {Name: "finish", Src: []string{"end"}, Dst: "finished"}, 740 | {Name: "reset", Src: []string{"end", "finished"}, Dst: "start"}, 741 | }, 742 | Callbacks{ 743 | "enter_end": func(ctx context.Context, e *Event) { 744 | if err := e.FSM.Event(ctx, "finish"); err != nil { 745 | fmt.Println(err) 746 | } 747 | }, 748 | "after_finish": func(ctx context.Context, e *Event) { 749 | afterFinishCalled = true 750 | if e.Src != "end" { 751 | panic(fmt.Sprintf("source should have been 'end' but was '%s'", e.Src)) 752 | } 753 | if err := e.FSM.Event(ctx, "reset"); err != nil { 754 | fmt.Println(err) 755 | } 756 | }, 757 | }, 758 | ) 759 | 760 | if err := fsm.Event(context.Background(), "run"); err != nil { 761 | t.Errorf("expected no error, got %v", err) 762 | } 763 | if !afterFinishCalled { 764 | t.Error("expected after_finish callback to have been executed but it wasn't") 765 | } 766 | 767 | currentState := fsm.Current() 768 | if currentState != "start" { 769 | t.Errorf("expected state to be 'start', was '%s'", currentState) 770 | } 771 | } 772 | 773 | func TestContextInCallbacks(t *testing.T) { 774 | var fsm *FSM 775 | var enterEndAsyncWorkDone = make(chan struct{}) 776 | fsm = NewFSM( 777 | "start", 778 | Events{ 779 | {Name: "run", Src: []string{"start"}, Dst: "end"}, 780 | {Name: "finish", Src: []string{"end"}, Dst: "finished"}, 781 | {Name: "reset", Src: []string{"end", "finished"}, Dst: "start"}, 782 | }, 783 | Callbacks{ 784 | "enter_end": func(ctx context.Context, e *Event) { 785 | go func() { 786 | <-ctx.Done() 787 | close(enterEndAsyncWorkDone) 788 | }() 789 | 790 | <-ctx.Done() 791 | if err := e.FSM.Event(ctx, "finish"); err != nil { 792 | e.Err = fmt.Errorf("transitioning to the finished state failed: %w", err) 793 | } 794 | }, 795 | }, 796 | ) 797 | 798 | ctx, cancel := context.WithCancel(context.Background()) 799 | go func() { 800 | cancel() 801 | }() 802 | err := fsm.Event(ctx, "run") 803 | if !errors.Is(err, context.Canceled) { 804 | t.Errorf("expected 'context canceled' error, got %v", err) 805 | } 806 | <-enterEndAsyncWorkDone 807 | 808 | currentState := fsm.Current() 809 | if currentState != "end" { 810 | t.Errorf("expected state to be 'end', was '%s'", currentState) 811 | } 812 | } 813 | 814 | func TestNoTransition(t *testing.T) { 815 | fsm := NewFSM( 816 | "start", 817 | Events{ 818 | {Name: "run", Src: []string{"start"}, Dst: "start"}, 819 | }, 820 | Callbacks{}, 821 | ) 822 | err := fsm.Event(context.Background(), "run") 823 | if _, ok := err.(NoTransitionError); !ok { 824 | t.Error("expected 'NoTransitionError'") 825 | } 826 | } 827 | 828 | func TestNoTransitionAfterEventCallbackTransition(t *testing.T) { 829 | var fsm *FSM 830 | fsm = NewFSM( 831 | "start", 832 | Events{ 833 | {Name: "run", Src: []string{"start"}, Dst: "start"}, 834 | {Name: "finish", Src: []string{"start"}, Dst: "finished"}, 835 | }, 836 | Callbacks{ 837 | "after_event": func(_ context.Context, e *Event) { 838 | fsm.Event(context.Background(), "finish") 839 | }, 840 | }, 841 | ) 842 | err := fsm.Event(context.Background(), "run") 843 | if _, ok := err.(NoTransitionError); !ok { 844 | t.Error("expected 'NoTransitionError'") 845 | } 846 | 847 | currentState := fsm.Current() 848 | if currentState != "finished" { 849 | t.Errorf("expected state to be 'finished', was '%s'", currentState) 850 | } 851 | } 852 | 853 | func ExampleNewFSM() { 854 | fsm := NewFSM( 855 | "green", 856 | Events{ 857 | {Name: "warn", Src: []string{"green"}, Dst: "yellow"}, 858 | {Name: "panic", Src: []string{"yellow"}, Dst: "red"}, 859 | {Name: "panic", Src: []string{"green"}, Dst: "red"}, 860 | {Name: "calm", Src: []string{"red"}, Dst: "yellow"}, 861 | {Name: "clear", Src: []string{"yellow"}, Dst: "green"}, 862 | }, 863 | Callbacks{ 864 | "before_warn": func(_ context.Context, e *Event) { 865 | fmt.Println("before_warn") 866 | }, 867 | "before_event": func(_ context.Context, e *Event) { 868 | fmt.Println("before_event") 869 | }, 870 | "leave_green": func(_ context.Context, e *Event) { 871 | fmt.Println("leave_green") 872 | }, 873 | "leave_state": func(_ context.Context, e *Event) { 874 | fmt.Println("leave_state") 875 | }, 876 | "enter_yellow": func(_ context.Context, e *Event) { 877 | fmt.Println("enter_yellow") 878 | }, 879 | "enter_state": func(_ context.Context, e *Event) { 880 | fmt.Println("enter_state") 881 | }, 882 | "after_warn": func(_ context.Context, e *Event) { 883 | fmt.Println("after_warn") 884 | }, 885 | "after_event": func(_ context.Context, e *Event) { 886 | fmt.Println("after_event") 887 | }, 888 | }, 889 | ) 890 | fmt.Println(fsm.Current()) 891 | err := fsm.Event(context.Background(), "warn") 892 | if err != nil { 893 | fmt.Println(err) 894 | } 895 | fmt.Println(fsm.Current()) 896 | // Output: 897 | // green 898 | // before_warn 899 | // before_event 900 | // leave_green 901 | // leave_state 902 | // enter_yellow 903 | // enter_state 904 | // after_warn 905 | // after_event 906 | // yellow 907 | } 908 | 909 | func ExampleFSM_Current() { 910 | fsm := NewFSM( 911 | "closed", 912 | Events{ 913 | {Name: "open", Src: []string{"closed"}, Dst: "open"}, 914 | {Name: "close", Src: []string{"open"}, Dst: "closed"}, 915 | }, 916 | Callbacks{}, 917 | ) 918 | fmt.Println(fsm.Current()) 919 | // Output: closed 920 | } 921 | 922 | func ExampleFSM_Is() { 923 | fsm := NewFSM( 924 | "closed", 925 | Events{ 926 | {Name: "open", Src: []string{"closed"}, Dst: "open"}, 927 | {Name: "close", Src: []string{"open"}, Dst: "closed"}, 928 | }, 929 | Callbacks{}, 930 | ) 931 | fmt.Println(fsm.Is("closed")) 932 | fmt.Println(fsm.Is("open")) 933 | // Output: 934 | // true 935 | // false 936 | } 937 | 938 | func ExampleFSM_Can() { 939 | fsm := NewFSM( 940 | "closed", 941 | Events{ 942 | {Name: "open", Src: []string{"closed"}, Dst: "open"}, 943 | {Name: "close", Src: []string{"open"}, Dst: "closed"}, 944 | }, 945 | Callbacks{}, 946 | ) 947 | fmt.Println(fsm.Can("open")) 948 | fmt.Println(fsm.Can("close")) 949 | // Output: 950 | // true 951 | // false 952 | } 953 | 954 | func ExampleFSM_AvailableTransitions() { 955 | fsm := NewFSM( 956 | "closed", 957 | Events{ 958 | {Name: "open", Src: []string{"closed"}, Dst: "open"}, 959 | {Name: "close", Src: []string{"open"}, Dst: "closed"}, 960 | {Name: "kick", Src: []string{"closed"}, Dst: "broken"}, 961 | }, 962 | Callbacks{}, 963 | ) 964 | // sort the results ordering is consistent for the output checker 965 | transitions := fsm.AvailableTransitions() 966 | sort.Strings(transitions) 967 | fmt.Println(transitions) 968 | // Output: 969 | // [kick open] 970 | } 971 | 972 | func ExampleFSM_Cannot() { 973 | fsm := NewFSM( 974 | "closed", 975 | Events{ 976 | {Name: "open", Src: []string{"closed"}, Dst: "open"}, 977 | {Name: "close", Src: []string{"open"}, Dst: "closed"}, 978 | }, 979 | Callbacks{}, 980 | ) 981 | fmt.Println(fsm.Cannot("open")) 982 | fmt.Println(fsm.Cannot("close")) 983 | // Output: 984 | // false 985 | // true 986 | } 987 | 988 | func ExampleFSM_Event() { 989 | fsm := NewFSM( 990 | "closed", 991 | Events{ 992 | {Name: "open", Src: []string{"closed"}, Dst: "open"}, 993 | {Name: "close", Src: []string{"open"}, Dst: "closed"}, 994 | }, 995 | Callbacks{}, 996 | ) 997 | fmt.Println(fsm.Current()) 998 | err := fsm.Event(context.Background(), "open") 999 | if err != nil { 1000 | fmt.Println(err) 1001 | } 1002 | fmt.Println(fsm.Current()) 1003 | err = fsm.Event(context.Background(), "close") 1004 | if err != nil { 1005 | fmt.Println(err) 1006 | } 1007 | fmt.Println(fsm.Current()) 1008 | // Output: 1009 | // closed 1010 | // open 1011 | // closed 1012 | } 1013 | 1014 | func ExampleFSM_Transition() { 1015 | fsm := NewFSM( 1016 | "closed", 1017 | Events{ 1018 | {Name: "open", Src: []string{"closed"}, Dst: "open"}, 1019 | {Name: "close", Src: []string{"open"}, Dst: "closed"}, 1020 | }, 1021 | Callbacks{ 1022 | "leave_closed": func(_ context.Context, e *Event) { 1023 | e.Async() 1024 | }, 1025 | }, 1026 | ) 1027 | err := fsm.Event(context.Background(), "open") 1028 | if e, ok := err.(AsyncError); !ok && e.Err != nil { 1029 | fmt.Println(err) 1030 | } 1031 | fmt.Println(fsm.Current()) 1032 | err = fsm.Transition() 1033 | if err != nil { 1034 | fmt.Println(err) 1035 | } 1036 | fmt.Println(fsm.Current()) 1037 | // Output: 1038 | // closed 1039 | // open 1040 | } 1041 | 1042 | func TestEventAndCanInGoroutines(t *testing.T) { 1043 | fsm := NewFSM( 1044 | "closed", 1045 | Events{ 1046 | {Name: "open", Src: []string{"closed"}, Dst: "open"}, 1047 | {Name: "close", Src: []string{"open"}, Dst: "closed"}, 1048 | }, 1049 | Callbacks{}, 1050 | ) 1051 | wg := sync.WaitGroup{} 1052 | for i := 0; i < 10; i++ { 1053 | wg.Add(2) 1054 | go func(n int) { 1055 | defer wg.Done() 1056 | if n%2 == 0 { 1057 | _ = fsm.Event(context.Background(), "open") 1058 | } else { 1059 | _ = fsm.Event(context.Background(), "close") 1060 | } 1061 | }(i) 1062 | go func() { 1063 | defer wg.Done() 1064 | fsm.Can("close") 1065 | }() 1066 | } 1067 | wg.Wait() 1068 | } 1069 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/looplab/fsm 2 | 3 | go 1.16 4 | -------------------------------------------------------------------------------- /graphviz_visualizer.go: -------------------------------------------------------------------------------- 1 | package fsm 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | ) 7 | 8 | // Visualize outputs a visualization of a FSM in Graphviz format. 9 | func Visualize(fsm *FSM) string { 10 | var buf bytes.Buffer 11 | 12 | // we sort the key alphabetically to have a reproducible graph output 13 | sortedEKeys := getSortedTransitionKeys(fsm.transitions) 14 | sortedStateKeys, _ := getSortedStates(fsm.transitions) 15 | 16 | writeHeaderLine(&buf) 17 | writeTransitions(&buf, sortedEKeys, fsm.transitions) 18 | writeStates(&buf, fsm.current, sortedStateKeys) 19 | writeFooter(&buf) 20 | 21 | return buf.String() 22 | } 23 | 24 | func writeHeaderLine(buf *bytes.Buffer) { 25 | buf.WriteString(`digraph fsm {`) 26 | buf.WriteString("\n") 27 | } 28 | 29 | func writeTransitions(buf *bytes.Buffer, sortedEKeys []eKey, transitions map[eKey]string) { 30 | for _, k := range sortedEKeys { 31 | v := transitions[k] 32 | buf.WriteString(fmt.Sprintf(` "%s" -> "%s" [ label = "%s" ];`, k.src, v, k.event)) 33 | buf.WriteString("\n") 34 | } 35 | 36 | buf.WriteString("\n") 37 | } 38 | 39 | func writeStates(buf *bytes.Buffer, current string, sortedStateKeys []string) { 40 | for _, k := range sortedStateKeys { 41 | if k == current { 42 | buf.WriteString(fmt.Sprintf(` "%s" [color = "red"];`, k)) 43 | } else { 44 | buf.WriteString(fmt.Sprintf(` "%s";`, k)) 45 | } 46 | buf.WriteString("\n") 47 | } 48 | } 49 | 50 | func writeFooter(buf *bytes.Buffer) { 51 | buf.WriteString(fmt.Sprintln("}")) 52 | } 53 | -------------------------------------------------------------------------------- /graphviz_visualizer_test.go: -------------------------------------------------------------------------------- 1 | package fsm 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestGraphvizOutput(t *testing.T) { 10 | fsmUnderTest := NewFSM( 11 | "closed", 12 | Events{ 13 | {Name: "open", Src: []string{"closed"}, Dst: "open"}, 14 | {Name: "close", Src: []string{"open"}, Dst: "closed"}, 15 | {Name: "part-close", Src: []string{"intermediate"}, Dst: "closed"}, 16 | }, 17 | Callbacks{}, 18 | ) 19 | 20 | got := Visualize(fsmUnderTest) 21 | wanted := ` 22 | digraph fsm { 23 | "closed" -> "open" [ label = "open" ]; 24 | "intermediate" -> "closed" [ label = "part-close" ]; 25 | "open" -> "closed" [ label = "close" ]; 26 | 27 | "closed" [color = "red"]; 28 | "intermediate"; 29 | "open"; 30 | }` 31 | normalizedGot := strings.ReplaceAll(got, "\n", "") 32 | normalizedWanted := strings.ReplaceAll(wanted, "\n", "") 33 | if normalizedGot != normalizedWanted { 34 | t.Errorf("build graphivz graph failed. \nwanted \n%s\nand got \n%s\n", wanted, got) 35 | fmt.Println([]byte(normalizedGot)) 36 | fmt.Println([]byte(normalizedWanted)) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /mermaid_visualizer.go: -------------------------------------------------------------------------------- 1 | package fsm 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | ) 7 | 8 | const highlightingColor = "#00AA00" 9 | 10 | // MermaidDiagramType the type of the mermaid diagram type 11 | type MermaidDiagramType string 12 | 13 | const ( 14 | // FlowChart the diagram type for output in flowchart style (https://mermaid-js.github.io/mermaid/#/flowchart) (including current state) 15 | FlowChart MermaidDiagramType = "flowChart" 16 | // StateDiagram the diagram type for output in stateDiagram style (https://mermaid-js.github.io/mermaid/#/stateDiagram) 17 | StateDiagram MermaidDiagramType = "stateDiagram" 18 | ) 19 | 20 | // VisualizeForMermaidWithGraphType outputs a visualization of a FSM in Mermaid format as specified by the graphType. 21 | func VisualizeForMermaidWithGraphType(fsm *FSM, graphType MermaidDiagramType) (string, error) { 22 | switch graphType { 23 | case FlowChart: 24 | return visualizeForMermaidAsFlowChart(fsm), nil 25 | case StateDiagram: 26 | return visualizeForMermaidAsStateDiagram(fsm), nil 27 | default: 28 | return "", fmt.Errorf("unknown MermaidDiagramType: %s", graphType) 29 | } 30 | } 31 | 32 | func visualizeForMermaidAsStateDiagram(fsm *FSM) string { 33 | var buf bytes.Buffer 34 | 35 | sortedTransitionKeys := getSortedTransitionKeys(fsm.transitions) 36 | 37 | buf.WriteString("stateDiagram-v2\n") 38 | buf.WriteString(fmt.Sprintln(` [*] -->`, fsm.current)) 39 | 40 | for _, k := range sortedTransitionKeys { 41 | v := fsm.transitions[k] 42 | buf.WriteString(fmt.Sprintf(` %s --> %s: %s`, k.src, v, k.event)) 43 | buf.WriteString("\n") 44 | } 45 | 46 | return buf.String() 47 | } 48 | 49 | // visualizeForMermaidAsFlowChart outputs a visualization of a FSM in Mermaid format (including highlighting of current state). 50 | func visualizeForMermaidAsFlowChart(fsm *FSM) string { 51 | var buf bytes.Buffer 52 | 53 | sortedTransitionKeys := getSortedTransitionKeys(fsm.transitions) 54 | sortedStates, statesToIDMap := getSortedStates(fsm.transitions) 55 | 56 | writeFlowChartGraphType(&buf) 57 | writeFlowChartStates(&buf, sortedStates, statesToIDMap) 58 | writeFlowChartTransitions(&buf, fsm.transitions, sortedTransitionKeys, statesToIDMap) 59 | writeFlowChartHighlightCurrent(&buf, fsm.current, statesToIDMap) 60 | 61 | return buf.String() 62 | } 63 | 64 | func writeFlowChartGraphType(buf *bytes.Buffer) { 65 | buf.WriteString("graph LR\n") 66 | } 67 | 68 | func writeFlowChartStates(buf *bytes.Buffer, sortedStates []string, statesToIDMap map[string]string) { 69 | for _, state := range sortedStates { 70 | buf.WriteString(fmt.Sprintf(` %s[%s]`, statesToIDMap[state], state)) 71 | buf.WriteString("\n") 72 | } 73 | 74 | buf.WriteString("\n") 75 | } 76 | 77 | func writeFlowChartTransitions(buf *bytes.Buffer, transitions map[eKey]string, sortedTransitionKeys []eKey, statesToIDMap map[string]string) { 78 | for _, transition := range sortedTransitionKeys { 79 | target := transitions[transition] 80 | buf.WriteString(fmt.Sprintf(` %s --> |%s| %s`, statesToIDMap[transition.src], transition.event, statesToIDMap[target])) 81 | buf.WriteString("\n") 82 | } 83 | buf.WriteString("\n") 84 | } 85 | 86 | func writeFlowChartHighlightCurrent(buf *bytes.Buffer, current string, statesToIDMap map[string]string) { 87 | buf.WriteString(fmt.Sprintf(` style %s fill:%s`, statesToIDMap[current], highlightingColor)) 88 | buf.WriteString("\n") 89 | } 90 | -------------------------------------------------------------------------------- /mermaid_visualizer_test.go: -------------------------------------------------------------------------------- 1 | package fsm 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestMermaidOutput(t *testing.T) { 10 | fsmUnderTest := NewFSM( 11 | "closed", 12 | Events{ 13 | {Name: "open", Src: []string{"closed"}, Dst: "open"}, 14 | {Name: "close", Src: []string{"open"}, Dst: "closed"}, 15 | {Name: "part-close", Src: []string{"intermediate"}, Dst: "closed"}, 16 | }, 17 | Callbacks{}, 18 | ) 19 | 20 | got, err := VisualizeForMermaidWithGraphType(fsmUnderTest, StateDiagram) 21 | if err != nil { 22 | t.Errorf("got error for visualizing with type MERMAID: %s", err) 23 | } 24 | wanted := ` 25 | stateDiagram-v2 26 | [*] --> closed 27 | closed --> open: open 28 | intermediate --> closed: part-close 29 | open --> closed: close 30 | ` 31 | normalizedGot := strings.ReplaceAll(got, "\n", "") 32 | normalizedWanted := strings.ReplaceAll(wanted, "\n", "") 33 | if normalizedGot != normalizedWanted { 34 | t.Errorf("build mermaid graph failed. \nwanted \n%s\nand got \n%s\n", wanted, got) 35 | fmt.Println([]byte(normalizedGot)) 36 | fmt.Println([]byte(normalizedWanted)) 37 | } 38 | } 39 | 40 | func TestMermaidFlowChartOutput(t *testing.T) { 41 | fsmUnderTest := NewFSM( 42 | "closed", 43 | Events{ 44 | {Name: "open", Src: []string{"closed"}, Dst: "open"}, 45 | {Name: "part-open", Src: []string{"closed"}, Dst: "intermediate"}, 46 | {Name: "part-open", Src: []string{"intermediate"}, Dst: "open"}, 47 | {Name: "close", Src: []string{"open"}, Dst: "closed"}, 48 | {Name: "part-close", Src: []string{"intermediate"}, Dst: "closed"}, 49 | }, 50 | Callbacks{}, 51 | ) 52 | 53 | got, err := VisualizeForMermaidWithGraphType(fsmUnderTest, FlowChart) 54 | if err != nil { 55 | t.Errorf("got error for visualizing with type MERMAID: %s", err) 56 | } 57 | wanted := ` 58 | graph LR 59 | id0[closed] 60 | id1[intermediate] 61 | id2[open] 62 | 63 | id0 --> |open| id2 64 | id0 --> |part-open| id1 65 | id1 --> |part-close| id0 66 | id1 --> |part-open| id2 67 | id2 --> |close| id0 68 | 69 | style id0 fill:#00AA00 70 | ` 71 | normalizedGot := strings.ReplaceAll(got, "\n", "") 72 | normalizedWanted := strings.ReplaceAll(wanted, "\n", "") 73 | if normalizedGot != normalizedWanted { 74 | t.Errorf("build mermaid graph failed. \nwanted \n%s\nand got \n%s\n", wanted, got) 75 | fmt.Println([]byte(normalizedGot)) 76 | fmt.Println([]byte(normalizedWanted)) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /uncancel_context.go: -------------------------------------------------------------------------------- 1 | package fsm 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | type uncancel struct { 9 | context.Context 10 | } 11 | 12 | func (*uncancel) Deadline() (deadline time.Time, ok bool) { return } 13 | func (*uncancel) Done() <-chan struct{} { return nil } 14 | func (*uncancel) Err() error { return nil } 15 | 16 | // uncancelContext returns a context which ignores the cancellation of the parent and only keeps the values. 17 | // Also returns a new cancel function. 18 | // This is useful to keep a background task running while the initial request is finished. 19 | func uncancelContext(ctx context.Context) (context.Context, context.CancelFunc) { 20 | return context.WithCancel(&uncancel{ctx}) 21 | } 22 | -------------------------------------------------------------------------------- /uncancel_context_test.go: -------------------------------------------------------------------------------- 1 | package fsm 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | func TestUncancel(t *testing.T) { 9 | t.Run("create a new context", func(t *testing.T) { 10 | t.Run("and cancel it", func(t *testing.T) { 11 | ctx := context.Background() 12 | ctx = context.WithValue(ctx, "key1", "value1") 13 | ctx, cancelFunc := context.WithCancel(ctx) 14 | cancelFunc() 15 | 16 | if ctx.Err() != context.Canceled { 17 | t.Errorf("expected context error 'context canceled', got %v", ctx.Err()) 18 | } 19 | select { 20 | case <-ctx.Done(): 21 | default: 22 | t.Error("expected context to be done but it wasn't") 23 | } 24 | 25 | t.Run("and uncancel it", func(t *testing.T) { 26 | ctx, newCancelFunc := uncancelContext(ctx) 27 | if ctx.Err() != nil { 28 | t.Errorf("expected context error to be nil, got %v", ctx.Err()) 29 | } 30 | select { 31 | case <-ctx.Done(): 32 | t.Fail() 33 | default: 34 | } 35 | 36 | t.Run("now it should still contain the values", func(t *testing.T) { 37 | if ctx.Value("key1") != "value1" { 38 | t.Errorf("expected context value of key 'key1' to be 'value1', got %v", ctx.Value("key1")) 39 | } 40 | }) 41 | t.Run("and cancel the child", func(t *testing.T) { 42 | newCancelFunc() 43 | if ctx.Err() != context.Canceled { 44 | t.Errorf("expected context error 'context canceled', got %v", ctx.Err()) 45 | } 46 | select { 47 | case <-ctx.Done(): 48 | default: 49 | t.Error("expected context to be done but it wasn't") 50 | } 51 | }) 52 | }) 53 | }) 54 | t.Run("and uncancel it", func(t *testing.T) { 55 | ctx := context.Background() 56 | parent := ctx 57 | ctx, newCancelFunc := uncancelContext(ctx) 58 | if ctx.Err() != nil { 59 | t.Errorf("expected context error to be nil, got %v", ctx.Err()) 60 | } 61 | select { 62 | case <-ctx.Done(): 63 | t.Fail() 64 | default: 65 | } 66 | 67 | t.Run("and cancel the child", func(t *testing.T) { 68 | newCancelFunc() 69 | if ctx.Err() != context.Canceled { 70 | t.Errorf("expected context error 'context canceled', got %v", ctx.Err()) 71 | } 72 | select { 73 | case <-ctx.Done(): 74 | default: 75 | t.Error("expected context to be done but it wasn't") 76 | } 77 | 78 | t.Run("and ensure the parent is not affected", func(t *testing.T) { 79 | if parent.Err() != nil { 80 | t.Errorf("expected parent context error to be nil, got %v", ctx.Err()) 81 | } 82 | select { 83 | case <-parent.Done(): 84 | t.Fail() 85 | default: 86 | } 87 | }) 88 | }) 89 | }) 90 | }) 91 | } 92 | -------------------------------------------------------------------------------- /visualizer.go: -------------------------------------------------------------------------------- 1 | package fsm 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | ) 7 | 8 | // VisualizeType the type of the visualization 9 | type VisualizeType string 10 | 11 | const ( 12 | // GRAPHVIZ the type for graphviz output (http://www.webgraphviz.com/) 13 | GRAPHVIZ VisualizeType = "graphviz" 14 | // MERMAID the type for mermaid output (https://mermaid-js.github.io/mermaid/#/stateDiagram) in the stateDiagram form 15 | MERMAID VisualizeType = "mermaid" 16 | // MermaidStateDiagram the type for mermaid output (https://mermaid-js.github.io/mermaid/#/stateDiagram) in the stateDiagram form 17 | MermaidStateDiagram VisualizeType = "mermaid-state-diagram" 18 | // MermaidFlowChart the type for mermaid output (https://mermaid-js.github.io/mermaid/#/flowchart) in the flow chart form 19 | MermaidFlowChart VisualizeType = "mermaid-flow-chart" 20 | ) 21 | 22 | // VisualizeWithType outputs a visualization of a FSM in the desired format. 23 | // If the type is not given it defaults to GRAPHVIZ 24 | func VisualizeWithType(fsm *FSM, visualizeType VisualizeType) (string, error) { 25 | switch visualizeType { 26 | case GRAPHVIZ: 27 | return Visualize(fsm), nil 28 | case MERMAID: 29 | return VisualizeForMermaidWithGraphType(fsm, StateDiagram) 30 | case MermaidStateDiagram: 31 | return VisualizeForMermaidWithGraphType(fsm, StateDiagram) 32 | case MermaidFlowChart: 33 | return VisualizeForMermaidWithGraphType(fsm, FlowChart) 34 | default: 35 | return "", fmt.Errorf("unknown VisualizeType: %s", visualizeType) 36 | } 37 | } 38 | 39 | func getSortedTransitionKeys(transitions map[eKey]string) []eKey { 40 | // we sort the key alphabetically to have a reproducible graph output 41 | sortedTransitionKeys := make([]eKey, 0) 42 | 43 | for transition := range transitions { 44 | sortedTransitionKeys = append(sortedTransitionKeys, transition) 45 | } 46 | sort.Slice(sortedTransitionKeys, func(i, j int) bool { 47 | if sortedTransitionKeys[i].src == sortedTransitionKeys[j].src { 48 | return sortedTransitionKeys[i].event < sortedTransitionKeys[j].event 49 | } 50 | return sortedTransitionKeys[i].src < sortedTransitionKeys[j].src 51 | }) 52 | 53 | return sortedTransitionKeys 54 | } 55 | 56 | func getSortedStates(transitions map[eKey]string) ([]string, map[string]string) { 57 | statesToIDMap := make(map[string]string) 58 | for transition, target := range transitions { 59 | if _, ok := statesToIDMap[transition.src]; !ok { 60 | statesToIDMap[transition.src] = "" 61 | } 62 | if _, ok := statesToIDMap[target]; !ok { 63 | statesToIDMap[target] = "" 64 | } 65 | } 66 | 67 | sortedStates := make([]string, 0, len(statesToIDMap)) 68 | for state := range statesToIDMap { 69 | sortedStates = append(sortedStates, state) 70 | } 71 | sort.Strings(sortedStates) 72 | 73 | for i, state := range sortedStates { 74 | statesToIDMap[state] = fmt.Sprintf("id%d", i) 75 | } 76 | return sortedStates, statesToIDMap 77 | } 78 | --------------------------------------------------------------------------------