From c1f602e189f7460e4cba024c5d24cd33c92f1ef0 Mon Sep 17 00:00:00 2001 From: Marcin Sulecki Date: Wed, 20 Dec 2023 15:47:34 +0100 Subject: [PATCH 01/18] Add mermaid graph format --- src/Stateless/Graph/MermaidGraph.cs | 23 ++++++ src/Stateless/Graph/MermaidGraphStyle.cs | 87 +++++++++++++++++++++ test/Stateless.Tests/MermaidGraphFixture.cs | 57 ++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 src/Stateless/Graph/MermaidGraph.cs create mode 100644 src/Stateless/Graph/MermaidGraphStyle.cs create mode 100644 test/Stateless.Tests/MermaidGraphFixture.cs diff --git a/src/Stateless/Graph/MermaidGraph.cs b/src/Stateless/Graph/MermaidGraph.cs new file mode 100644 index 00000000..699562ee --- /dev/null +++ b/src/Stateless/Graph/MermaidGraph.cs @@ -0,0 +1,23 @@ +using Stateless.Reflection; + +namespace Stateless.Graph +{ + /// + /// Class to generate a MermaidGraph + /// + public static class MermaidGraph + { + /// + /// Generate a Mermaid graph from the state machine info + /// + /// + /// + public static string Format(StateMachineInfo machineInfo) + { + var graph = new StateGraph(machineInfo); + + return graph.ToGraph(new MermaidGraphStyle()); + } + + } +} diff --git a/src/Stateless/Graph/MermaidGraphStyle.cs b/src/Stateless/Graph/MermaidGraphStyle.cs new file mode 100644 index 00000000..bf1fc5ef --- /dev/null +++ b/src/Stateless/Graph/MermaidGraphStyle.cs @@ -0,0 +1,87 @@ +using Stateless.Reflection; +using System; +using System.Collections.Generic; +using System.Reflection.Emit; +using System.Text; + +namespace Stateless.Graph +{ + /// + /// Class to generate a graph in mermaid format + /// + public class MermaidGraphStyle : GraphStyleBase + { + /// + /// Returns the formatted text for a single superstate and its substates. + /// For example, for DOT files this would be a subgraph containing nodes for all the substates. + /// + /// The superstate to generate text for + /// Description of the superstate, and all its substates, in the desired format + public override string FormatOneCluster(SuperState stateInfo) + { + string stateRepresentationString = ""; + return stateRepresentationString; + } + + /// + /// Generate the text for a single decision node + /// + /// Name of the node + /// Label for the node + /// + public override string FormatOneDecisionNode(string nodeName, string label) + { + return String.Empty; + } + + /// + /// Generate the text for a single state + /// + /// The state to generate text for + /// + public override string FormatOneState(State state) + { + return String.Empty; + } + + /// Get the text that starts a new graph + /// + public override string GetPrefix() + { + return "stateDiagram-v2"; + } + + /// + /// + /// + /// + /// + public override string GetInitialTransition(StateInfo initialState) + { + return $"\r\n[*] --> {initialState}"; + } + + + + /// + /// + /// + /// + /// + /// + /// + /// + /// + public override string FormatOneTransition(string sourceNodeName, string trigger, IEnumerable actions, string destinationNodeName, IEnumerable guards) + { + string label = trigger ?? ""; + + return FormatOneLine(sourceNodeName, destinationNodeName, label); + } + + internal string FormatOneLine(string fromNodeName, string toNodeName, string label) + { + return $"\t{fromNodeName} --> {toNodeName} : {label}"; + } + } +} diff --git a/test/Stateless.Tests/MermaidGraphFixture.cs b/test/Stateless.Tests/MermaidGraphFixture.cs new file mode 100644 index 00000000..18b84404 --- /dev/null +++ b/test/Stateless.Tests/MermaidGraphFixture.cs @@ -0,0 +1,57 @@ +using Xunit; + +namespace Stateless.Tests +{ + public class MermaidGraphFixture + { + [Fact] + public void Format_InitialTransition_ShouldReturns() + { + var expected = "stateDiagram-v2\r\n[*] --> A"; + + var sm = new StateMachine(State.A); + + var result = Graph.MermaidGraph.Format(sm.GetInfo()); + + Assert.Equal(expected, result); + + } + + [Fact] + public void Format_SimpleTransition() + { + var expected = "stateDiagram-v2\r\n\tA --> B : X\r\n[*] --> A"; + + var sm = new StateMachine(State.A); + + sm.Configure(State.A) + .Permit(Trigger.X, State.B); + + var result = Graph.MermaidGraph.Format(sm.GetInfo()); + + Assert.Equal(expected, result); + + } + + [Fact] + public void TwoSimpleTransitions() + { + var expected = """ + stateDiagram-v2 + A --> B : X + A --> C : Y + """; + + var sm = new StateMachine(State.A); + + sm.Configure(State.A) + .Permit(Trigger.X, State.B) + .Permit(Trigger.Y, State.C); + + var result = Graph.MermaidGraph.Format(sm.GetInfo()); + + Assert.Equal(expected, result); + + } + } +} From 13840d62151cc8d0a4cd9b8ffdfdf7fe36280afa Mon Sep 17 00:00:00 2001 From: Marcin Sulecki Date: Wed, 20 Dec 2023 15:50:49 +0100 Subject: [PATCH 02/18] Added export to mermaid graph --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 9a1aecd1..ee1e152a 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Some useful extensions are also provided: * Parameterised triggers * Reentrant states * Export to DOT graph + * Export to mermaid graph ### Hierarchical States From 84a7d9c18e67482dc0e8b6b75981587fd17eff8c Mon Sep 17 00:00:00 2001 From: Marcin Sulecki Date: Wed, 20 Dec 2023 15:59:56 +0100 Subject: [PATCH 03/18] Add export description to Mermaid --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index ee1e152a..a0208075 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,33 @@ digraph { This can then be rendered by tools that support the DOT graph language, such as the [dot command line tool](http://www.graphviz.org/doc/info/command.html) from [graphviz.org](http://www.graphviz.org) or [viz.js](https://github.com/mdaines/viz.js). See http://www.webgraphviz.com for instant gratification. Command line example: `dot -T pdf -o phoneCall.pdf phoneCall.dot` to generate a PDF file. +### Export to mermaid graph + +It can be useful to visualize state machines on runtime. With this approach the code is the authoritative source and state diagrams are by-products which are always up to date. + +```csharp +phoneCall.Configure(State.OffHook) + .PermitIf(Trigger.CallDialled, State.Ringing); + +string graph = MermaidGraph.Format(phoneCall.GetInfo()); +``` + +The `MermaidGraph.Format()` method returns a string representation of the state machine in the [Mermaid](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/creating-diagrams#creating-mermaid-diagrams), e.g.: + +``` +stateDiagram-v2 + [*] --> OffHook + OffHook --> Ringing : CallDialled +``` + +This can then be rendered by GitHub or [Obsidian](https://github.com/obsidianmd) + +``` mermaid +stateDiagram-v2 + [*] --> OffHook + OffHook --> Ringing : CallDialled +``` + ### Async triggers On platforms that provide `Task`, the `StateMachine` supports `async` entry/exit actions and so-on: From c81534a6f6216cc46bed3090fc031e456a92373b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Gr=C3=BCtzmacher?= <44983012+lg2de@users.noreply.github.com> Date: Fri, 14 Jun 2024 09:37:11 +0200 Subject: [PATCH 04/18] Use license expression --- src/Stateless/Stateless.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Stateless/Stateless.csproj b/src/Stateless/Stateless.csproj index a3d0771c..74efdb22 100644 --- a/src/Stateless/Stateless.csproj +++ b/src/Stateless/Stateless.csproj @@ -17,7 +17,7 @@ true Stateless.png https://github.com/dotnet-state-machine/stateless - http://www.apache.org/licenses/LICENSE-2.0 + Apache-2.0 false Apache-2.0 git From c924b88931d79226d9c8902ce2585b062e859aaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Gr=C3=BCtzmacher?= <44983012+lg2de@users.noreply.github.com> Date: Fri, 14 Jun 2024 15:11:55 +0200 Subject: [PATCH 05/18] Remove duplicate PackageLicenseExpression --- src/Stateless/Stateless.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Stateless/Stateless.csproj b/src/Stateless/Stateless.csproj index 74efdb22..8abca638 100644 --- a/src/Stateless/Stateless.csproj +++ b/src/Stateless/Stateless.csproj @@ -17,7 +17,6 @@ true Stateless.png https://github.com/dotnet-state-machine/stateless - Apache-2.0 false Apache-2.0 git From 3760d58d6977cbb6bad9a79e9d5908d862f57d94 Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Fri, 21 Jun 2024 16:31:15 +0100 Subject: [PATCH 06/18] Mermaid graph features: graph direction; support state names with spaces; support substates. --- src/Stateless/Graph/MermaidGraph.cs | 9 +- src/Stateless/Graph/MermaidGraphDirection.cs | 17 + src/Stateless/Graph/MermaidGraphStyle.cs | 152 ++++++-- src/Stateless/Graph/StateGraph.cs | 9 +- src/Stateless/Graph/UmlDotGraphStyle.cs | 95 ++--- test/Stateless.Tests/DotGraphFixture.cs | 59 +-- test/Stateless.Tests/MermaidGraphFixture.cs | 369 ++++++++++++++++++- 7 files changed, 589 insertions(+), 121 deletions(-) create mode 100644 src/Stateless/Graph/MermaidGraphDirection.cs diff --git a/src/Stateless/Graph/MermaidGraph.cs b/src/Stateless/Graph/MermaidGraph.cs index 699562ee..c3d99fff 100644 --- a/src/Stateless/Graph/MermaidGraph.cs +++ b/src/Stateless/Graph/MermaidGraph.cs @@ -1,4 +1,5 @@ using Stateless.Reflection; +using System.Collections; namespace Stateless.Graph { @@ -11,13 +12,15 @@ public static class MermaidGraph /// Generate a Mermaid graph from the state machine info /// /// + /// + /// When set, includes a direction setting in the output indicating the direction of flow. + /// /// - public static string Format(StateMachineInfo machineInfo) + public static string Format(StateMachineInfo machineInfo, MermaidGraphDirection? direction = null) { var graph = new StateGraph(machineInfo); - return graph.ToGraph(new MermaidGraphStyle()); + return graph.ToGraph(new MermaidGraphStyle(graph, direction)); } - } } diff --git a/src/Stateless/Graph/MermaidGraphDirection.cs b/src/Stateless/Graph/MermaidGraphDirection.cs new file mode 100644 index 00000000..344d6810 --- /dev/null +++ b/src/Stateless/Graph/MermaidGraphDirection.cs @@ -0,0 +1,17 @@ +namespace Stateless.Graph +{ + /// + /// The directions of flow that can be chosen for a Mermaid graph. + /// + public enum MermaidGraphDirection + { + /// Left-to-right flow + LeftToRight, + /// Right-to-left flow + RightToLeft, + /// Top-to-bottom flow + TopToBottom, + /// Bottom-to-top flow + BottomToTop + } +} diff --git a/src/Stateless/Graph/MermaidGraphStyle.cs b/src/Stateless/Graph/MermaidGraphStyle.cs index bf1fc5ef..7080c080 100644 --- a/src/Stateless/Graph/MermaidGraphStyle.cs +++ b/src/Stateless/Graph/MermaidGraphStyle.cs @@ -1,7 +1,7 @@ using Stateless.Reflection; using System; using System.Collections.Generic; -using System.Reflection.Emit; +using System.Linq; using System.Text; namespace Stateless.Graph @@ -11,16 +11,37 @@ namespace Stateless.Graph /// public class MermaidGraphStyle : GraphStyleBase { + private readonly StateGraph _graph; + private readonly MermaidGraphDirection? _direction; + private readonly Dictionary _stateMap = new Dictionary(); + private bool _stateMapInitialized = false; + /// - /// Returns the formatted text for a single superstate and its substates. - /// For example, for DOT files this would be a subgraph containing nodes for all the substates. + /// Create a new instance of /// - /// The superstate to generate text for - /// Description of the superstate, and all its substates, in the desired format + /// The state graph + /// When non-null, sets the flow direction in the output. + public MermaidGraphStyle(StateGraph graph, MermaidGraphDirection? direction) + : base() + { + _graph = graph; + _direction = direction; + } + + /// public override string FormatOneCluster(SuperState stateInfo) { - string stateRepresentationString = ""; - return stateRepresentationString; + StringBuilder sb = new StringBuilder(); + sb.AppendLine(); + sb.AppendLine($"\tstate {GetSanitizedStateName(stateInfo.StateName)} {{"); + foreach (var subState in stateInfo.SubStates) + { + sb.AppendLine($"\t\t{GetSanitizedStateName(subState.StateName)}"); + } + + sb.Append("\t}"); + + return sb.ToString(); } /// @@ -31,57 +52,122 @@ public override string FormatOneCluster(SuperState stateInfo) /// public override string FormatOneDecisionNode(string nodeName, string label) { - return String.Empty; + return $"{Environment.NewLine}\tstate {nodeName} <>"; } - /// - /// Generate the text for a single state - /// - /// The state to generate text for - /// + /// public override string FormatOneState(State state) { - return String.Empty; + return string.Empty; } /// Get the text that starts a new graph /// public override string GetPrefix() { - return "stateDiagram-v2"; + BuildSanitizedNamedStateMap(); + string prefix = "stateDiagram-v2"; + if (_direction.HasValue) + { + prefix += $"{Environment.NewLine}\tdirection {GetDirectionCode(_direction.Value)}"; + } + + foreach (var state in _stateMap.Where(x => !x.Key.Equals(x.Value.StateName, StringComparison.Ordinal))) + { + prefix += $"{Environment.NewLine}\t{state.Key} : {state.Value.StateName}"; + } + + return prefix; } - /// - /// - /// - /// - /// + /// public override string GetInitialTransition(StateInfo initialState) { - return $"\r\n[*] --> {initialState}"; - } + var sanitizedStateName = GetSanitizedStateName(initialState.ToString()); - + return $"{Environment.NewLine}[*] --> {sanitizedStateName}"; + } - /// - /// - /// - /// - /// - /// - /// - /// - /// + /// public override string FormatOneTransition(string sourceNodeName, string trigger, IEnumerable actions, string destinationNodeName, IEnumerable guards) { string label = trigger ?? ""; - return FormatOneLine(sourceNodeName, destinationNodeName, label); + if (actions?.Count() > 0) + label += " / " + string.Join(", ", actions); + + if (guards.Any()) + { + foreach (var info in guards) + { + if (label.Length > 0) + label += " "; + label += "[" + info + "]"; + } + } + + var sanitizedSourceNodeName = GetSanitizedStateName(sourceNodeName); + var sanitizedDestinationNodeName = GetSanitizedStateName(destinationNodeName); + + return FormatOneLine(sanitizedSourceNodeName, sanitizedDestinationNodeName, label); } internal string FormatOneLine(string fromNodeName, string toNodeName, string label) { return $"\t{fromNodeName} --> {toNodeName} : {label}"; } + + private static string GetDirectionCode(MermaidGraphDirection direction) + { + switch(direction) + { + case MermaidGraphDirection.TopToBottom: + return "TB"; + case MermaidGraphDirection.BottomToTop: + return "BT"; + case MermaidGraphDirection.LeftToRight: + return "LR"; + case MermaidGraphDirection.RightToLeft: + return "RL"; + default: + throw new ArgumentOutOfRangeException(nameof(direction), direction, $"Unsupported {nameof(MermaidGraphDirection)}: {direction}."); + } + } + + private void BuildSanitizedNamedStateMap() + { + if (_stateMapInitialized) + { + return; + } + + // Ensures that state names are unique and do not contain characters that would cause an invalid Mermaid graph. + var uniqueAliases = new HashSet(); + foreach (var state in _graph.States) + { + var sanitizedStateName = string.Concat(state.Value.StateName.Where(c => !(char.IsWhiteSpace(c) || c == ':' || c == '-'))); + if (!sanitizedStateName.Equals(state.Value.StateName, StringComparison.Ordinal)) + { + int count = 1; + var tempName = sanitizedStateName; + while (uniqueAliases.Contains(tempName) || _graph.States.ContainsKey(tempName)) + { + tempName = $"{sanitizedStateName}_{count++}"; + } + + sanitizedStateName = tempName; + uniqueAliases.Add(sanitizedStateName); + } + + _stateMap[sanitizedStateName] = state.Value; + } + + _stateMapInitialized = true; + } + + private string GetSanitizedStateName(string stateName) + { + return _stateMap.FirstOrDefault(x => x.Value.StateName == stateName).Key ?? stateName; + } } } diff --git a/src/Stateless/Graph/StateGraph.cs b/src/Stateless/Graph/StateGraph.cs index 3477bfd4..ff32e7aa 100644 --- a/src/Stateless/Graph/StateGraph.cs +++ b/src/Stateless/Graph/StateGraph.cs @@ -58,12 +58,12 @@ public StateGraph(StateMachineInfo machineInfo) /// public string ToGraph(GraphStyleBase style) { - string dirgraphText = style.GetPrefix().Replace("\n", System.Environment.NewLine); + string dirgraphText = style.GetPrefix(); // Start with the clusters foreach (var state in States.Values.Where(x => x is SuperState)) { - dirgraphText += style.FormatOneCluster((SuperState)state).Replace("\n", System.Environment.NewLine); + dirgraphText += style.FormatOneCluster((SuperState)state); } // Next process all non-cluster states @@ -71,14 +71,13 @@ public string ToGraph(GraphStyleBase style) { if (state is SuperState || state is Decision || state.SuperState != null) continue; - dirgraphText += style.FormatOneState(state).Replace("\n", System.Environment.NewLine); + dirgraphText += style.FormatOneState(state); } // Finally, add decision nodes foreach (var dec in Decisions) { - dirgraphText += style.FormatOneDecisionNode(dec.NodeName, dec.Method.Description) - .Replace("\n", System.Environment.NewLine); + dirgraphText += style.FormatOneDecisionNode(dec.NodeName, dec.Method.Description); } // now build behaviours diff --git a/src/Stateless/Graph/UmlDotGraphStyle.cs b/src/Stateless/Graph/UmlDotGraphStyle.cs index 7d2f5bd0..8f1b53f1 100644 --- a/src/Stateless/Graph/UmlDotGraphStyle.cs +++ b/src/Stateless/Graph/UmlDotGraphStyle.cs @@ -7,63 +7,67 @@ namespace Stateless.Graph { /// - /// Generate DOT graphs in basic UML style. + /// Generate DOT graphs in basic UML style /// public class UmlDotGraphStyle : GraphStyleBase { - /// Get the text that starts a new graph. - /// The prefix for the DOT graph document. + /// Get the text that starts a new graph + /// public override string GetPrefix() { - return "digraph {\n" - + "compound=true;\n" - + "node [shape=Mrecord]\n" - + "rankdir=\"LR\"\n"; + var sb = new StringBuilder(); + sb.AppendLine("digraph {") + .AppendLine("compound=true;") + .AppendLine("node [shape=Mrecord]") + .AppendLine("rankdir=\"LR\""); + + return sb.ToString(); } /// /// Returns the formatted text for a single superstate and its substates. + /// For example, for DOT files this would be a subgraph containing nodes for all the substates. /// - /// A DOT graph representation of the superstate and all its substates. - /// + /// The superstate to generate text for + /// Description of the superstate, and all its substates, in the desired format public override string FormatOneCluster(SuperState stateInfo) { - string stateRepresentationString = ""; + var sb = new StringBuilder(); var sourceName = stateInfo.StateName; - StringBuilder label = new StringBuilder($"{sourceName}"); + StringBuilder label = new StringBuilder(sourceName); - if (stateInfo.EntryActions.Count > 0 || stateInfo.ExitActions.Count > 0) + if (stateInfo.EntryActions.Any() || stateInfo.ExitActions.Any()) { - label.Append("\\n----------"); - label.Append(string.Concat(stateInfo.EntryActions.Select(act => "\\nentry / " + act))); - label.Append(string.Concat(stateInfo.ExitActions.Select(act => "\\nexit / " + act))); + label.Append($"{Environment.NewLine}----------") + .Append(string.Concat(stateInfo.EntryActions.Select(act => $"{Environment.NewLine}entry / {act}"))) + .Append(string.Concat(stateInfo.ExitActions.Select(act => $"{Environment.NewLine}exit / {act}"))); } - stateRepresentationString = "\n" - + $"subgraph \"cluster{stateInfo.NodeName}\"" + "\n" - + "\t{" + "\n" - + $"\tlabel = \"{label.ToString()}\"" + "\n"; + sb.AppendLine() + .AppendLine($"subgraph \"cluster{stateInfo.NodeName}\"") + .AppendLine("\t{") + .AppendLine($"\tlabel = \"{label.ToString()}\""); foreach (var subState in stateInfo.SubStates) { - stateRepresentationString += FormatOneState(subState); + sb.Append(FormatOneState(subState)); } - stateRepresentationString += "}\n"; + sb.AppendLine("}"); - return stateRepresentationString; + return sb.ToString(); } /// - /// Generate the text for a single state. + /// Generate the text for a single state /// - /// A DOT graph representation of the state. - /// + /// The state to generate text for + /// public override string FormatOneState(State state) { if (state.EntryActions.Count == 0 && state.ExitActions.Count == 0) - return $"\"{state.StateName}\" [label=\"{state.StateName}\"];\n"; + return $"\"{state.StateName}\" [label=\"{state.StateName}\"];{Environment.NewLine}"; string f = $"\"{state.StateName}\" [label=\"{state.StateName}|"; @@ -71,18 +75,22 @@ public override string FormatOneState(State state) es.AddRange(state.EntryActions.Select(act => "entry / " + act)); es.AddRange(state.ExitActions.Select(act => "exit / " + act)); - f += String.Join("\\n", es); + f += string.Join(Environment.NewLine, es); - f += "\"];\n"; + f += $"\"];{Environment.NewLine}"; return f; } /// - /// Generate text for a single transition. + /// Generate text for a single transition /// - /// A DOT graph representation of a state transition. - /// + /// + /// + /// + /// + /// + /// public override string FormatOneTransition(string sourceNodeName, string trigger, IEnumerable actions, string destinationNodeName, IEnumerable guards) { string label = trigger ?? ""; @@ -104,20 +112,26 @@ public override string FormatOneTransition(string sourceNodeName, string trigger } /// - /// Generate the text for a single decision node. + /// Generate the text for a single decision node /// - /// A DOT graph representation of the decision node for a dynamic transition. - /// + /// Name of the node + /// Label for the node + /// public override string FormatOneDecisionNode(string nodeName, string label) { - return $"\"{nodeName}\" [shape = \"diamond\", label = \"{label}\"];\n"; + return $"\"{nodeName}\" [shape = \"diamond\", label = \"{label}\"];{Environment.NewLine}"; + } + + internal string FormatOneLine(string fromNodeName, string toNodeName, string label) + { + return $"\"{fromNodeName}\" -> \"{toNodeName}\" [style=\"solid\", label=\"{label}\"];"; } /// - /// Get initial transition if present. + /// /// - /// A DOT graph representation of the initial state transition. - /// + /// + /// public override string GetInitialTransition(StateInfo initialState) { var initialStateName = initialState.UnderlyingState.ToString(); @@ -128,10 +142,5 @@ public override string GetInitialTransition(StateInfo initialState) return dirgraphText; } - - internal string FormatOneLine(string fromNodeName, string toNodeName, string label) - { - return $"\"{fromNodeName}\" -> \"{toNodeName}\" [style=\"solid\", label=\"{label}\"];"; - } } } diff --git a/test/Stateless.Tests/DotGraphFixture.cs b/test/Stateless.Tests/DotGraphFixture.cs index b16234b4..5704c695 100644 --- a/test/Stateless.Tests/DotGraphFixture.cs +++ b/test/Stateless.Tests/DotGraphFixture.cs @@ -71,7 +71,7 @@ string Box(Style style, string label, List entries = null, List b = $"\"{label}\" [label=\"{label}\"];\n"; else { - b = $"\"{label}\"" + " [label=\"" + label + "|" + String.Join("\\n", es) + "\"];\n"; + b = $"\"{label}\"" + " [label=\"" + label + "|" + String.Join("\n", es) + "\"];\n"; } return b.Replace("\n", Environment.NewLine); @@ -135,25 +135,6 @@ public void SimpleTransition() Assert.Equal(expected, dotGraph); } - [Fact] - public void SimpleTransitionUML() - { - var expected = Prefix(Style.UML) + Box(Style.UML, "A") + Box(Style.UML, "B") + Line("A", "B", "X") + suffix; - - var sm = new StateMachine(State.A); - - sm.Configure(State.A) - .Permit(Trigger.X, State.B); - - string dotGraph = UmlDotGraph.Format(sm.GetInfo()); - -#if WRITE_DOTS_TO_FOLDER - System.IO.File.WriteAllText(DestinationFolder + "SimpleTransitionUML.dot", dotGraph); -#endif - - Assert.Equal(expected, dotGraph); - } - [Fact] public void TwoSimpleTransitions() { @@ -168,7 +149,13 @@ public void TwoSimpleTransitions() .Permit(Trigger.X, State.B) .Permit(Trigger.Y, State.C); - Assert.Equal(expected, UmlDotGraph.Format(sm.GetInfo())); + string dotGraph = UmlDotGraph.Format(sm.GetInfo()); + +#if WRITE_DOTS_TO_FOLDER + System.IO.File.WriteAllText(DestinationFolder + "TwoSimpleTransitions.dot", dotGraph); +#endif + + Assert.Equal(expected, dotGraph); } [Fact] @@ -185,7 +172,13 @@ public void WhenDiscriminatedByAnonymousGuard() .PermitIf(Trigger.X, State.B, anonymousGuard); sm.Configure(State.B); - Assert.Equal(expected, UmlDotGraph.Format(sm.GetInfo())); + string dotGraph = UmlDotGraph.Format(sm.GetInfo()); + +#if WRITE_DOTS_TO_FOLDER + System.IO.File.WriteAllText(DestinationFolder + "WhenDiscriminatedByAnonymousGuard.dot", dotGraph); +#endif + + Assert.Equal(expected, dotGraph); } [Fact] @@ -225,7 +218,13 @@ public void WhenDiscriminatedByNamedDelegate() sm.Configure(State.A) .PermitIf(Trigger.X, State.B, IsTrue); - Assert.Equal(expected, UmlDotGraph.Format(sm.GetInfo())); + string dotGraph = UmlDotGraph.Format(sm.GetInfo()); + +#if WRITE_DOTS_TO_FOLDER + System.IO.File.WriteAllText(DestinationFolder + "WhenDiscriminatedByNamedDelegate.dot", dotGraph); +#endif + + Assert.Equal(expected, dotGraph); } [Fact] @@ -362,7 +361,13 @@ public void TransitionWithIgnore() .Ignore(Trigger.Y) .Permit(Trigger.X, State.B); - Assert.Equal(expected, UmlDotGraph.Format(sm.GetInfo())); + string dotGraph = UmlDotGraph.Format(sm.GetInfo()); + +#if WRITE_DOTS_TO_FOLDER + System.IO.File.WriteAllText(DestinationFolder + "TransitionWithIgnore.dot", dotGraph); +#endif + + Assert.Equal(expected, dotGraph); } [Fact] @@ -410,7 +415,7 @@ public void SpacedUmlWithSubstate() string TriggerY = "Trigger Y"; var expected = Prefix(Style.UML) - + Subgraph(Style.UML, StateD, $"{StateD}\\n----------\\nentry / Enter D", + + Subgraph(Style.UML, StateD, $"{StateD}\n----------\nentry / Enter D", Box(Style.UML, StateB) + Box(Style.UML, StateC)) + Box(Style.UML, StateA, new List { "Enter A" }, new List { "Exit A" }) @@ -437,7 +442,7 @@ public void SpacedUmlWithSubstate() string dotGraph = UmlDotGraph.Format(sm.GetInfo()); #if WRITE_DOTS_TO_FOLDER - System.IO.File.WriteAllText(DestinationFolder + "UmlWithSubstate.dot", dotGraph); + System.IO.File.WriteAllText(DestinationFolder + "SpacedUmlWithSubstate.dot", dotGraph); #endif Assert.Equal(expected, dotGraph); @@ -447,7 +452,7 @@ public void SpacedUmlWithSubstate() public void UmlWithSubstate() { var expected = Prefix(Style.UML) - + Subgraph(Style.UML, "D", "D\\n----------\\nentry / EnterD", + + Subgraph(Style.UML, "D", "D\n----------\nentry / EnterD", Box(Style.UML, "B") + Box(Style.UML, "C")) + Box(Style.UML, "A", new List { "EnterA" }, new List { "ExitA" }) diff --git a/test/Stateless.Tests/MermaidGraphFixture.cs b/test/Stateless.Tests/MermaidGraphFixture.cs index 18b84404..02042ee5 100644 --- a/test/Stateless.Tests/MermaidGraphFixture.cs +++ b/test/Stateless.Tests/MermaidGraphFixture.cs @@ -1,4 +1,5 @@ -using Xunit; +using System.Text; +using Xunit; namespace Stateless.Tests { @@ -7,20 +8,28 @@ public class MermaidGraphFixture [Fact] public void Format_InitialTransition_ShouldReturns() { - var expected = "stateDiagram-v2\r\n[*] --> A"; + var expected = new StringBuilder() + .AppendLine("stateDiagram-v2") + .AppendLine("[*] --> A") + .ToString().TrimEnd(); var sm = new StateMachine(State.A); var result = Graph.MermaidGraph.Format(sm.GetInfo()); - Assert.Equal(expected, result); + WriteToFile(nameof(Format_InitialTransition_ShouldReturns), result); + Assert.Equal(expected, result); } [Fact] - public void Format_SimpleTransition() + public void SimpleTransition() { - var expected = "stateDiagram-v2\r\n\tA --> B : X\r\n[*] --> A"; + var expected = new StringBuilder() + .AppendLine("stateDiagram-v2") + .AppendLine(" A --> B : X") + .AppendLine("[*] --> A") + .ToString().TrimEnd(); var sm = new StateMachine(State.A); @@ -29,18 +38,42 @@ public void Format_SimpleTransition() var result = Graph.MermaidGraph.Format(sm.GetInfo()); + WriteToFile(nameof(SimpleTransition), result); + Assert.Equal(expected, result); + } + [Fact] + public void SimpleTransition_LeftToRight() + { + var expected = new StringBuilder() + .AppendLine("stateDiagram-v2") + .AppendLine(" direction LR") + .AppendLine(" A --> B : X") + .AppendLine("[*] --> A") + .ToString().TrimEnd(); + + var sm = new StateMachine(State.A); + + sm.Configure(State.A) + .Permit(Trigger.X, State.B); + + var result = Graph.MermaidGraph.Format(sm.GetInfo(), Graph.MermaidGraphDirection.LeftToRight); + + WriteToFile(nameof(SimpleTransition_LeftToRight), result); + + Assert.Equal(expected, result); } [Fact] public void TwoSimpleTransitions() { - var expected = """ - stateDiagram-v2 - A --> B : X - A --> C : Y - """; + var expected = new StringBuilder() + .AppendLine("stateDiagram-v2") + .AppendLine(" A --> B : X") + .AppendLine(" A --> C : Y") + .AppendLine("[*] --> A") + .ToString().TrimEnd(); var sm = new StateMachine(State.A); @@ -50,8 +83,324 @@ public void TwoSimpleTransitions() var result = Graph.MermaidGraph.Format(sm.GetInfo()); + WriteToFile(nameof(TwoSimpleTransitions), result); + + Assert.Equal(expected, result); + } + + [Fact] + public void WhenDiscriminatedByAnonymousGuard() + { + var expected = new StringBuilder() + .AppendLine("stateDiagram-v2") + .AppendLine(" A --> B : X [Function]") + .AppendLine("[*] --> A") + .ToString().TrimEnd(); + + bool anonymousGuard() => true; + + var sm = new StateMachine(State.A); + + sm.Configure(State.A) + .PermitIf(Trigger.X, State.B, anonymousGuard); + sm.Configure(State.B); + + var result = Graph.MermaidGraph.Format(sm.GetInfo()); + + WriteToFile(nameof(WhenDiscriminatedByAnonymousGuard), result); + + Assert.Equal(expected, result); + } + + [Fact] + public void WhenDiscriminatedByAnonymousGuardWithDescription() + { + var expected = new StringBuilder() + .AppendLine("stateDiagram-v2") + .AppendLine(" A --> B : X [description]") + .AppendLine("[*] --> A") + .ToString().TrimEnd(); + + bool guardFunction() => true; + + var sm = new StateMachine(State.A); + + sm.Configure(State.A) + .PermitIf(Trigger.X, State.B, guardFunction, "description"); + sm.Configure(State.B); + + var result = Graph.MermaidGraph.Format(sm.GetInfo()); + + WriteToFile(nameof(WhenDiscriminatedByAnonymousGuard), result); + + Assert.Equal(expected, result); + } + + [Fact] + public void WhenDiscriminatedByNamedDelegate() + { + var expected = new StringBuilder() + .AppendLine("stateDiagram-v2") + .AppendLine(" A --> B : X [IsTrue]") + .AppendLine("[*] --> A") + .ToString().TrimEnd(); + + var sm = new StateMachine(State.A); + + sm.Configure(State.A) + .PermitIf(Trigger.X, State.B, IsTrue); + + var result = Graph.MermaidGraph.Format(sm.GetInfo()); + + WriteToFile(nameof(WhenDiscriminatedByNamedDelegate), result); + + Assert.Equal(expected, result); + } + + [Fact] + public void WhenDiscriminatedByNamedDelegateWithDescription() + { + var expected = new StringBuilder() + .AppendLine("stateDiagram-v2") + .AppendLine(" A --> B : X [description]") + .AppendLine("[*] --> A") + .ToString().TrimEnd(); + + var sm = new StateMachine(State.A); + + sm.Configure(State.A) + .PermitIf(Trigger.X, State.B, IsTrue, "description"); + + var result = Graph.MermaidGraph.Format(sm.GetInfo()); + + WriteToFile(nameof(WhenDiscriminatedByNamedDelegateWithDescription), result); + + Assert.Equal(expected, result); + } + + [Fact] + public void DestinationStateIsDynamic() + { + var expected = new StringBuilder() + .AppendLine("stateDiagram-v2") + .AppendLine(" state Decision1 <>") + .AppendLine(" A --> Decision1 : X") + .AppendLine("[*] --> A") + .ToString().TrimEnd(); + + var sm = new StateMachine(State.A); + sm.Configure(State.A) + .PermitDynamic(Trigger.X, () => State.B); + + var result = Graph.MermaidGraph.Format(sm.GetInfo()); + + WriteToFile(nameof(DestinationStateIsDynamic), result); + + Assert.Equal(expected, result); + } + + [Fact] + public void DestinationStateIsCalculatedBasedOnTriggerParameters() + { + var expected = new StringBuilder() + .AppendLine("stateDiagram-v2") + .AppendLine(" state Decision1 <>") + .AppendLine(" A --> Decision1 : X") + .AppendLine("[*] --> A") + .ToString().TrimEnd(); + + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitDynamic(trigger, i => i == 1 ? State.B : State.C); + + var result = Graph.MermaidGraph.Format(sm.GetInfo()); + + WriteToFile(nameof(DestinationStateIsCalculatedBasedOnTriggerParameters), result); + + Assert.Equal(expected, result); + } + + [Fact] + public void TransitionWithIgnore() + { + // This test duplicates the behaviour expressed in the TransitionWithIgnore test in DotGraphFixture, + // but it seems counter-intuitive to show the ignored trigger as a transition back to the same state. + var expected = new StringBuilder() + .AppendLine("stateDiagram-v2") + .AppendLine(" A --> B : X") + .AppendLine(" A --> A : Y") + .AppendLine("[*] --> A") + .ToString().TrimEnd(); + + var sm = new StateMachine(State.A); + + sm.Configure(State.A) + .Ignore(Trigger.Y) + .Permit(Trigger.X, State.B); + + var result = Graph.MermaidGraph.Format(sm.GetInfo()); + + WriteToFile(nameof(TransitionWithIgnore), result); + + Assert.Equal(expected, result); + } + + [Fact] + public void OnEntryWithTriggerParameter() + { + var expected = new StringBuilder() + .AppendLine("stateDiagram-v2") + .AppendLine(" A --> B : X / BX") + .AppendLine(" A --> C : Y / TestEntryActionString [IsTriggerY]") + .AppendLine(" A --> B : Z [IsTriggerZ]") + .AppendLine("[*] --> A") + .ToString().TrimEnd(); + + bool anonymousGuard() => true; + var sm = new StateMachine(State.A); + var parmTrig = sm.SetTriggerParameters(Trigger.Y); + + sm.Configure(State.A) + .OnEntry(() => { }, "OnEntry") + .Permit(Trigger.X, State.B) + .PermitIf(Trigger.Y, State.C, anonymousGuard, "IsTriggerY") + .PermitIf(Trigger.Z, State.B, anonymousGuard, "IsTriggerZ"); + + sm.Configure(State.B) + .OnEntryFrom(Trigger.X, TestEntryAction, "BX"); + + sm.Configure(State.C) + .OnEntryFrom(parmTrig, TestEntryActionString); + + var result = Graph.MermaidGraph.Format(sm.GetInfo()); + + WriteToFile(nameof(TransitionWithIgnore), result); + + Assert.Equal(expected, result); + } + + [Fact] + public void SpacedWithSubstate() + { + string StateA = "State A"; + string StateB = "State B"; + string StateC = "State C"; + string StateD = "State D"; + string TriggerX = "Trigger X"; + string TriggerY = "Trigger Y"; + + var expected = new StringBuilder() + .AppendLine("stateDiagram-v2") + .AppendLine(" StateD : State D") + .AppendLine(" StateB : State B") + .AppendLine(" StateC : State C") + .AppendLine(" StateA : State A") + .AppendLine(" state StateD {") + .AppendLine(" StateB") + .AppendLine(" StateC") + .AppendLine(" }") + .AppendLine(" StateA --> StateB : Trigger X") + .AppendLine(" StateA --> StateC : Trigger Y") + .AppendLine("[*] --> StateA") + .ToString().TrimEnd(); + + var sm = new StateMachine("State A"); + + sm.Configure(StateA) + .Permit(TriggerX, StateB) + .Permit(TriggerY, StateC) + .OnEntry(TestEntryAction, "Enter A") + .OnExit(TestEntryAction, "Exit A"); + + sm.Configure(StateB) + .SubstateOf(StateD); + sm.Configure(StateC) + .SubstateOf(StateD); + sm.Configure(StateD) + .OnEntry(TestEntryAction, "Enter D"); + + var result = Graph.MermaidGraph.Format(sm.GetInfo()); + + WriteToFile(nameof(SpacedWithSubstate), result); + + Assert.Equal(expected, result); + } + + [Fact] + public void WithSubstate() + { + var expected = new StringBuilder() + .AppendLine("stateDiagram-v2") + .AppendLine(" state D {") + .AppendLine(" B") + .AppendLine(" C") + .AppendLine(" }") + .AppendLine(" A --> B : X") + .AppendLine(" A --> C : Y") + .AppendLine("[*] --> A") + .ToString().TrimEnd(); + + var sm = new StateMachine(State.A); + + sm.Configure(State.A) + .Permit(Trigger.X, State.B) + .Permit(Trigger.Y, State.C); + + sm.Configure(State.B) + .SubstateOf(State.D); + sm.Configure(State.C) + .SubstateOf(State.D); + sm.Configure(State.D); + + var result = Graph.MermaidGraph.Format(sm.GetInfo()); + + WriteToFile(nameof(WithSubstate), result); + Assert.Equal(expected, result); + } + + [Fact] + public void StateNamesWithSpacesAreAliased() + { + var expected = new StringBuilder() + .AppendLine("stateDiagram-v2") + .AppendLine(" AA : A A") + .AppendLine(" AA_1 : A A") + .AppendLine(" AA_2 : A A") + .AppendLine(" AA --> B : X") + .AppendLine(" AA_1 --> B : X") + .AppendLine(" AA_2 --> B : X") + .AppendLine("[*] --> AA") + .ToString().TrimEnd(); + + var sm = new StateMachine("A A"); + + sm.Configure("A A").Permit(Trigger.X, "B"); + sm.Configure("A A").Permit(Trigger.X, "B"); + sm.Configure("A A").Permit(Trigger.X, "B"); + + var result = Graph.MermaidGraph.Format(sm.GetInfo()); + WriteToFile(nameof(StateNamesWithSpacesAreAliased), result); + + Assert.Equal(expected, result); + } + + private bool IsTrue() + { + return true; + } + + private void TestEntryAction() { } + + private void TestEntryActionString(string val) { } + + private void WriteToFile(string fileName, string content) + { +#if WRITE_DOTS_TO_FOLDER + System.IO.File.WriteAllText(System.IO.Path.Combine("c:\\temp", $"{fileName}.txt"), content); +#endif } } } From 48ce60a7d845405bc5d62c55a2e66b17a226770b Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Wed, 3 Jul 2024 21:51:56 +0100 Subject: [PATCH 07/18] Fix entry function being shown on internal transitions in dot graph output #587 --- src/Stateless/Graph/StateGraph.cs | 2 +- .../Reflection/FixedTransitionInfo.cs | 3 ++- src/Stateless/Reflection/TransitionInfo.cs | 5 ++++ test/Stateless.Tests/DotGraphFixture.cs | 24 +++++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/Stateless/Graph/StateGraph.cs b/src/Stateless/Graph/StateGraph.cs index 3477bfd4..ec05045a 100644 --- a/src/Stateless/Graph/StateGraph.cs +++ b/src/Stateless/Graph/StateGraph.cs @@ -137,7 +137,7 @@ void AddTransitions(StateMachineInfo machineInfo) State toState = States[fix.DestinationState.UnderlyingState.ToString()]; if (fromState == toState) { - StayTransition stay = new StayTransition(fromState, fix.Trigger, fix.GuardConditionsMethodDescriptions, true); + StayTransition stay = new StayTransition(fromState, fix.Trigger, fix.GuardConditionsMethodDescriptions, !fix.IsInternalTransition); Transitions.Add(stay); fromState.Leaving.Add(stay); fromState.Arriving.Add(stay); diff --git a/src/Stateless/Reflection/FixedTransitionInfo.cs b/src/Stateless/Reflection/FixedTransitionInfo.cs index 0aed3ec9..60275476 100644 --- a/src/Stateless/Reflection/FixedTransitionInfo.cs +++ b/src/Stateless/Reflection/FixedTransitionInfo.cs @@ -15,7 +15,8 @@ internal static FixedTransitionInfo Create(StateMachine() : behaviour.Guard.Conditions.Select(c => c.MethodDescription) + ? new List() : behaviour.Guard.Conditions.Select(c => c.MethodDescription), + IsInternalTransition = behaviour is StateMachine.InternalTriggerBehaviour }; return transition; diff --git a/src/Stateless/Reflection/TransitionInfo.cs b/src/Stateless/Reflection/TransitionInfo.cs index 4cd53321..75ef1367 100644 --- a/src/Stateless/Reflection/TransitionInfo.cs +++ b/src/Stateless/Reflection/TransitionInfo.cs @@ -17,5 +17,10 @@ public abstract class TransitionInfo /// Returns a non-null but empty list if there are no guard conditions /// public IEnumerable GuardConditionsMethodDescriptions; + + /// + /// When true, the transition is internal and does not invoke the entry/exit actions of the state. + /// + public bool IsInternalTransition { get; protected set; } } } diff --git a/test/Stateless.Tests/DotGraphFixture.cs b/test/Stateless.Tests/DotGraphFixture.cs index b16234b4..784c262c 100644 --- a/test/Stateless.Tests/DotGraphFixture.cs +++ b/test/Stateless.Tests/DotGraphFixture.cs @@ -537,6 +537,30 @@ public void TransitionWithIgnoreAndEntry() Assert.Equal(expected, dotGraph); } + [Fact] + public void Internal_Transition_Does_Not_Show_Entry_Exit_Functions() + { + var expected = Prefix(Style.UML) + + Box(Style.UML, "A", new List { "DoEntry" }, new List { "DoExit" }) + + Line("A", "A", "X [Function]") + + suffix; + + var sm = new StateMachine(State.A); + + sm.Configure(State.A) + .OnEntry(x => { }, "DoEntry") + .OnExit(x => { }, "DoExit") + .InternalTransition(Trigger.X, x => { }); + + string dotGraph = UmlDotGraph.Format(sm.GetInfo()); + +#if WRITE_DOTS_TO_FOLDER + System.IO.File.WriteAllText(DestinationFolder + "Internal_Transition_Does_Not_Show_Entry_Exit_Functions.dot", dotGraph); +#endif + + Assert.Equal(expected, dotGraph); + } + [Fact] public void Initial_State_Not_Changed_After_Trigger_Fired() { From 5483b5b59ca4d5d4c3b9bdbeadf5894b70b7a9ba Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Sun, 7 Jul 2024 16:53:55 +0100 Subject: [PATCH 08/18] Show state entry function in re-entry transition in dot graph --- src/Stateless/Graph/GraphStyleBase.cs | 10 ++------- src/Stateless/Graph/StateGraph.cs | 10 +++++++++ test/Stateless.Tests/DotGraphFixture.cs | 30 +++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/Stateless/Graph/GraphStyleBase.cs b/src/Stateless/Graph/GraphStyleBase.cs index f7139c6a..0b79c5df 100644 --- a/src/Stateless/Graph/GraphStyleBase.cs +++ b/src/Stateless/Graph/GraphStyleBase.cs @@ -77,17 +77,11 @@ public virtual List FormatAllTransitions(List transitions) line = FormatOneTransition(stay.SourceState.NodeName, stay.Trigger.UnderlyingTrigger.ToString(), null, stay.SourceState.NodeName, stay.Guards.Select(x => x.Description)); } - else if (stay.SourceState.EntryActions.Count == 0) - { - line = FormatOneTransition(stay.SourceState.NodeName, stay.Trigger.UnderlyingTrigger.ToString(), - null, stay.SourceState.NodeName, stay.Guards.Select(x => x.Description)); - } else { - // There are entry functions into the state, so call out that this transition - // does invoke them (since normally a transition back into the same state doesn't) line = FormatOneTransition(stay.SourceState.NodeName, stay.Trigger.UnderlyingTrigger.ToString(), - stay.SourceState.EntryActions, stay.SourceState.NodeName, stay.Guards.Select(x => x.Description)); + stay.DestinationEntryActions.Select(x => x.Method.Description), + stay.SourceState.NodeName, stay.Guards.Select(x => x.Description)); } } else diff --git a/src/Stateless/Graph/StateGraph.cs b/src/Stateless/Graph/StateGraph.cs index ec05045a..0e460201 100644 --- a/src/Stateless/Graph/StateGraph.cs +++ b/src/Stateless/Graph/StateGraph.cs @@ -141,6 +141,16 @@ void AddTransitions(StateMachineInfo machineInfo) Transitions.Add(stay); fromState.Leaving.Add(stay); fromState.Arriving.Add(stay); + + // If the reentrant transition causes the state's entry action to be executed, this is shown + // explicity in the state graph by adding it to the DestinationEntryActions list. + if (stay.ExecuteEntryExitActions) + { + foreach (var action in stateInfo.EntryActions.Where(a => a.FromTrigger is null)) + { + stay.DestinationEntryActions.Add(action); + } + } } else { diff --git a/test/Stateless.Tests/DotGraphFixture.cs b/test/Stateless.Tests/DotGraphFixture.cs index 784c262c..0d11eae5 100644 --- a/test/Stateless.Tests/DotGraphFixture.cs +++ b/test/Stateless.Tests/DotGraphFixture.cs @@ -582,6 +582,36 @@ public void Initial_State_Not_Changed_After_Trigger_Fired() Assert.Equal(expected, dotGraph); } + [Fact] + public void Reentrant_Transition_Shows_Entry_Action_When_Trigger_Has_Parameters() + { + var expected = Prefix(Style.UML) + + Box(Style.UML, "A") + + Box(Style.UML, "B") + + Line("A", "B", "X / LogTrigger") + + Line("B", "B", "X / LogTrigger") + + suffix; + + var sm = new StateMachine(State.A); + var triggerX = sm.SetTriggerParameters(Trigger.X); + + sm.Configure(State.A) + .Permit(Trigger.X, State.B); + + var list = new List(); + sm.Configure(State.B) + .OnEntryFrom(triggerX, list.Add, entryActionDescription: "LogTrigger") + .PermitReentry(Trigger.X); + + string dotGraph = UmlDotGraph.Format(sm.GetInfo()); + +#if WRITE_DOTS_TO_FOLDER + System.IO.File.WriteAllText(DestinationFolder + "Reentrant_Transition_Shows_Entry_Action_When_Trigger_Has_Parameters.dot", dotGraph); +#endif + + Assert.Equal(expected, dotGraph); + } + private void TestEntryAction() { } private void TestEntryActionString(string val) { } private State DestinationSelector() { return State.A; } From 6b2cb9f8284aaeee6a1acba2ad318fb9f3468530 Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Sun, 7 Jul 2024 19:41:08 +0100 Subject: [PATCH 09/18] Test dot graph output for reentrant transition without parameterized trigger --- test/Stateless.Tests/DotGraphFixture.cs | 33 +++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/test/Stateless.Tests/DotGraphFixture.cs b/test/Stateless.Tests/DotGraphFixture.cs index 0d11eae5..70b65aa3 100644 --- a/test/Stateless.Tests/DotGraphFixture.cs +++ b/test/Stateless.Tests/DotGraphFixture.cs @@ -583,7 +583,36 @@ public void Initial_State_Not_Changed_After_Trigger_Fired() } [Fact] - public void Reentrant_Transition_Shows_Entry_Action_When_Trigger_Has_Parameters() + public void Reentrant_Transition_Shows_Entry_Action_When_Action_Is_Configured_With_OnEntryFrom() + { + var expected = Prefix(Style.UML) + + Box(Style.UML, "A") + + Box(Style.UML, "B") + + Line("A", "B", "X / OnEntry") + + Line("B", "B", "X / OnEntry") + + suffix; + + var sm = new StateMachine(State.A); + + sm.Configure(State.A) + .Permit(Trigger.X, State.B); + + var list = new List(); + sm.Configure(State.B) + .OnEntryFrom(Trigger.X, OnEntry, entryActionDescription: "OnEntry") + .PermitReentry(Trigger.X); + + string dotGraph = UmlDotGraph.Format(sm.GetInfo()); + +#if WRITE_DOTS_TO_FOLDER + System.IO.File.WriteAllText(DestinationFolder + "Reentrant_Transition_Shows_Entry_Action_When_Action_Is_Configured_With_OnEntryFrom.dot", dotGraph); +#endif + + Assert.Equal(expected, dotGraph); + } + + [Fact] + public void Reentrant_Transition_Shows_Entry_Action_When_Action_Is_Configured_With_OnEntryFrom_And_Trigger_Has_Parameter() { var expected = Prefix(Style.UML) + Box(Style.UML, "A") @@ -606,7 +635,7 @@ public void Reentrant_Transition_Shows_Entry_Action_When_Trigger_Has_Parameters( string dotGraph = UmlDotGraph.Format(sm.GetInfo()); #if WRITE_DOTS_TO_FOLDER - System.IO.File.WriteAllText(DestinationFolder + "Reentrant_Transition_Shows_Entry_Action_When_Trigger_Has_Parameters.dot", dotGraph); + System.IO.File.WriteAllText(DestinationFolder + "Reentrant_Transition_Shows_Entry_Action_When_Action_Is_Configured_With_OnEntryFrom_And_Trigger_Has_Parameter.dot", dotGraph); #endif Assert.Equal(expected, dotGraph); From 6c0811bf99206eae3c6c6b7025d1cdd08ebe0461 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Mon, 15 Jul 2024 17:56:13 -0600 Subject: [PATCH 10/18] Allow async PermitDynamic --- .../DynamicTriggerBehaviour.Async.cs | 27 ++++++++++++++++ src/Stateless/StateConfiguration.Async.cs | 31 +++++++++++++++++++ src/Stateless/StateMachine.Async.cs | 9 ++++++ src/Stateless/StateMachine.cs | 12 +++++++ 4 files changed, 79 insertions(+) create mode 100644 src/Stateless/DynamicTriggerBehaviour.Async.cs diff --git a/src/Stateless/DynamicTriggerBehaviour.Async.cs b/src/Stateless/DynamicTriggerBehaviour.Async.cs new file mode 100644 index 00000000..6f3ad8dc --- /dev/null +++ b/src/Stateless/DynamicTriggerBehaviour.Async.cs @@ -0,0 +1,27 @@ +using System; +using System.Threading.Tasks; + +namespace Stateless +{ + public partial class StateMachine + { + internal class DynamicTriggerBehaviourAsync : TriggerBehaviour + { + readonly Func> _destination; + internal Reflection.DynamicTransitionInfo TransitionInfo { get; private set; } + + public DynamicTriggerBehaviourAsync(TTrigger trigger, Func> destination, + TransitionGuard transitionGuard, Reflection.DynamicTransitionInfo info) + : base(trigger, transitionGuard) + { + _destination = destination ?? throw new ArgumentNullException(nameof(destination)); + TransitionInfo = info ?? throw new ArgumentNullException(nameof(info)); + } + + public async Task GetDestinationState(TState source, object[] args) + { + return await _destination(args); + } + } + } +} diff --git a/src/Stateless/StateConfiguration.Async.cs b/src/Stateless/StateConfiguration.Async.cs index 53a8c918..af7234e1 100644 --- a/src/Stateless/StateConfiguration.Async.cs +++ b/src/Stateless/StateConfiguration.Async.cs @@ -522,6 +522,37 @@ public StateConfiguration OnExitAsync(Func exitAction, string Reflection.InvocationInfo.Create(exitAction, exitActionDescription, Reflection.InvocationInfo.Timing.Asynchronous)); return this; } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Async function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Optional description for the async function to calculate the state + /// Optional array of possible destination states (used by output formatters) + /// The receiver. + public StateConfiguration PermitDynamicAsync(TTrigger trigger, Func> destinationStateSelector, + string destinationStateSelectorDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + _representation.AddTriggerBehaviour( + new DynamicTriggerBehaviourAsync(trigger, + args => destinationStateSelector(), + null, // No transition guard + Reflection.DynamicTransitionInfo.Create(trigger, + null, // No guards + Reflection.InvocationInfo.Create(destinationStateSelector, destinationStateSelectorDescription), + possibleDestinationStates + ) + )); + return this; + } + } } } diff --git a/src/Stateless/StateMachine.Async.cs b/src/Stateless/StateMachine.Async.cs index f69d0065..01f3b8f4 100644 --- a/src/Stateless/StateMachine.Async.cs +++ b/src/Stateless/StateMachine.Async.cs @@ -217,6 +217,15 @@ async Task InternalFireOneAsync(TTrigger trigger, params object[] args) // Handle transition, and set new state var transition = new Transition(source, handler.Destination, trigger, args); await HandleReentryTriggerAsync(args, representativeState, transition); + break; + } + case DynamicTriggerBehaviourAsync asyncHandler: + { + var destination = await asyncHandler.GetDestinationState(source, args); + // Handle transition, and set new state; reentry is permitted from dynamic trigger behaviours. + var transition = new Transition(source, destination, trigger, args); + await HandleTransitioningTriggerAsync(args, representativeState, transition); + break; } case DynamicTriggerBehaviour handler: diff --git a/src/Stateless/StateMachine.cs b/src/Stateless/StateMachine.cs index a28c5f48..31e9de65 100644 --- a/src/Stateless/StateMachine.cs +++ b/src/Stateless/StateMachine.cs @@ -422,6 +422,18 @@ private void InternalFireOne(TTrigger trigger, params object[] args) HandleReentryTrigger(args, representativeState, transition); break; } + case DynamicTriggerBehaviourAsync asyncHandler: + { + asyncHandler.GetDestinationState(source, args) + .ContinueWith(t => + { + var destination = t.Result; + // Handle transition, and set new state; reentry is permitted from dynamic trigger behaviours. + var transition = new Transition(source, destination, trigger, args); + return HandleTransitioningTriggerAsync(args, representativeState, transition); + }); + break; + } case DynamicTriggerBehaviour handler: { handler.GetDestinationState(source, args, out var destination); From e2332362bbc48f5a6d3e4889157a94f7c5edf388 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Mon, 15 Jul 2024 18:21:40 -0600 Subject: [PATCH 11/18] Add and run tests --- src/Stateless/StateConfiguration.Async.cs | 525 ++++++++++++++++++ ...ynamicAsyncTriggerBehaviourAsyncFixture.cs | 168 ++++++ 2 files changed, 693 insertions(+) create mode 100644 test/Stateless.Tests/DynamicAsyncTriggerBehaviourAsyncFixture.cs diff --git a/src/Stateless/StateConfiguration.Async.cs b/src/Stateless/StateConfiguration.Async.cs index af7234e1..6dafc717 100644 --- a/src/Stateless/StateConfiguration.Async.cs +++ b/src/Stateless/StateConfiguration.Async.cs @@ -1,6 +1,7 @@ #if TASKS using System; +using System.Linq; using System.Threading.Tasks; namespace Stateless @@ -553,6 +554,530 @@ public StateConfiguration PermitDynamicAsync(TTrigger trigger, Func return this; } + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Optional description of the function to calculate the state + /// Optional list of possible target states. + /// The receiver. + /// Type of the first trigger argument. + public StateConfiguration PermitDynamicAsync(TriggerWithParameters trigger, Func> destinationStateSelector, + string destinationStateSelectorDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + + _representation.AddTriggerBehaviour( + new DynamicTriggerBehaviourAsync(trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0)), + null, // No transition guards + Reflection.DynamicTransitionInfo.Create(trigger.Trigger, + null, // No guards + Reflection.InvocationInfo.Create(destinationStateSelector, destinationStateSelectorDescription), + possibleDestinationStates) + )); + return this; + + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Function that must return true in order for the + /// trigger to be accepted. + /// Guard description + /// Optional list of possible target states. + /// The receiver. + public StateConfiguration PermitDynamicIfAsync(TTrigger trigger, Func> destinationStateSelector, + Func guard, string guardDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + return PermitDynamicIfAsync(trigger, destinationStateSelector, null, guard, guardDescription, possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Description of the function to calculate the state + /// Function that must return true in order for the + /// trigger to be accepted. + /// Guard description + /// Optional list of possible target states. + /// The receiver. + public StateConfiguration PermitDynamicIfAsync(TTrigger trigger, Func> destinationStateSelector, + string destinationStateSelectorDescription, Func guard, string guardDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger, + args => destinationStateSelector(), + destinationStateSelectorDescription, + new TransitionGuard(guard, guardDescription), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Functions and their descriptions that must return true in order for the + /// trigger to be accepted. + /// Optional list of possible target states. + /// The receiver. + public StateConfiguration PermitDynamicIfAsync(TTrigger trigger, Func> destinationStateSelector, Reflection.DynamicStateInfos possibleDestinationStates = null, params Tuple, string>[] guards) + { + return PermitDynamicIfAsync(trigger, destinationStateSelector, null, possibleDestinationStates, guards); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Description of the function to calculate the state + /// Functions and their descriptions that must return true in order for the + /// trigger to be accepted. + /// Optional list of possible target states. + /// The receiver. + public StateConfiguration PermitDynamicIfAsync(TTrigger trigger, Func> destinationStateSelector, + string destinationStateSelectorDescription, Reflection.DynamicStateInfos possibleDestinationStates = null, params Tuple, string>[] guards) + { + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger, + args => destinationStateSelector(), + destinationStateSelectorDescription, + new TransitionGuard(guards), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Function that must return true in order for the + /// trigger to be accepted. + /// Guard description + /// Optional list of possible target states. + /// The receiver. + /// Type of the first trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Func guard, string guardDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0)), + null, // destinationStateSelectorString + new TransitionGuard(guard, guardDescription), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// The receiver. + /// Type of the first trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector) + { + return PermitDynamicIfAsync(trigger, destinationStateSelector, null, new Tuple, string>[0]); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Optional list of possible target states. + /// Functions and their descriptions that must return true in order for the + /// trigger to be accepted. + /// The receiver. + /// Type of the first trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Reflection.DynamicStateInfos possibleDestinationStates = null, params Tuple, string>[] guards) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0)), + null, // destinationStateSelectorString + new TransitionGuard(guards), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Function that must return true in order for the + /// trigger to be accepted. + /// Guard description + /// Optional list of possible target states. + /// The receiver. + /// Type of the first trigger argument. + /// Type of the second trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Func guard, string guardDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0), + ParameterConversion.Unpack(args, 1)), + null, // destinationStateSelectorString + new TransitionGuard(guard, guardDescription), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Optional list of possible target states. + /// Functions and their descriptions that must return true in order for the + /// trigger to be accepted. + /// The receiver. + /// Type of the first trigger argument. + /// Type of the second trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Reflection.DynamicStateInfos possibleDestinationStates = null, params Tuple, string>[] guards) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0), + ParameterConversion.Unpack(args, 1)), + null, // destinationStateSelectorString + new TransitionGuard(guards), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// The receiver. + /// Function that must return true in order for the + /// trigger to be accepted. + /// Guard description + /// Optional list of possible target states. + /// Type of the first trigger argument. + /// Type of the second trigger argument. + /// Type of the third trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Func guard, string guardDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0), + ParameterConversion.Unpack(args, 1), + ParameterConversion.Unpack(args, 2)), + null, // destinationStateSelectorString + new TransitionGuard(guard, guardDescription), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Optional list of possible target states. + /// The receiver. + /// Functions ant their descriptions that must return true in order for the + /// trigger to be accepted. + /// Type of the first trigger argument. + /// Type of the second trigger argument. + /// Type of the third trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Reflection.DynamicStateInfos possibleDestinationStates = null, params Tuple, string>[] guards) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0), + ParameterConversion.Unpack(args, 1), + ParameterConversion.Unpack(args, 2)), + null, // destinationStateSelectorString + new TransitionGuard(guards), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Parameterized Function that must return true in order for the + /// trigger to be accepted. + /// Guard description + /// Optional list of possible target states. + /// The receiver. + /// Type of the first trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Func guard, string guardDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0)), + null, // destinationStateSelectorString + new TransitionGuard(TransitionGuard.ToPackedGuard(guard), guardDescription), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Optional list of possible target states. + /// Functions and their descriptions that must return true in order for the + /// trigger to be accepted. + /// The receiver. + /// Type of the first trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Reflection.DynamicStateInfos possibleDestinationStates = null, params Tuple, string>[] guards) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0)), + null, // destinationStateSelectorString + new TransitionGuard(TransitionGuard.ToPackedGuards(guards)), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Function that must return true in order for the + /// trigger to be accepted. + /// Guard description + /// Optional list of possible target states. + /// The receiver. + /// Type of the first trigger argument. + /// Type of the second trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Func guard, string guardDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0), + ParameterConversion.Unpack(args, 1)), + null, // destinationStateSelectorString + new TransitionGuard(TransitionGuard.ToPackedGuard(guard), guardDescription), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Functions that must return true in order for the + /// trigger to be accepted. + /// Optional list of possible target states. + /// The receiver. + /// Type of the first trigger argument. + /// Type of the second trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Tuple, string>[] guards, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0), + ParameterConversion.Unpack(args, 1)), + null, // destinationStateSelectorString + new TransitionGuard(TransitionGuard.ToPackedGuards(guards)), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Function that must return true in order for the + /// trigger to be accepted. + /// Guard description + /// Optional list of possible target states. + /// The receiver. + /// Type of the first trigger argument. + /// Type of the second trigger argument. + /// Type of the third trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Func guard, string guardDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0), + ParameterConversion.Unpack(args, 1), + ParameterConversion.Unpack(args, 2)), + null, // destinationStateSelectorString + new TransitionGuard(TransitionGuard.ToPackedGuard(guard), guardDescription), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Functions that must return true in order for the + /// trigger to be accepted. + /// Optional list of possible target states. + /// The receiver. + /// Type of the first trigger argument. + /// Type of the second trigger argument. + /// Type of the third trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Tuple, string>[] guards, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0), + ParameterConversion.Unpack(args, 1), + ParameterConversion.Unpack(args, 2)), + null, // destinationStateSelectorString + new TransitionGuard(TransitionGuard.ToPackedGuards(guards)), + possibleDestinationStates); + } + StateConfiguration InternalPermitDynamicIfAsync(TTrigger trigger, Func> destinationStateSelector, + string destinationStateSelectorDescription, TransitionGuard transitionGuard, Reflection.DynamicStateInfos possibleDestinationStates) + { + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + if (transitionGuard == null) throw new ArgumentNullException(nameof(transitionGuard)); + + _representation.AddTriggerBehaviour(new DynamicTriggerBehaviourAsync(trigger, + destinationStateSelector, + transitionGuard, + Reflection.DynamicTransitionInfo.Create(trigger, + transitionGuard.Conditions.Select(x => x.MethodDescription), + Reflection.InvocationInfo.Create(destinationStateSelector, destinationStateSelectorDescription), + possibleDestinationStates) + )); + return this; + } } } } diff --git a/test/Stateless.Tests/DynamicAsyncTriggerBehaviourAsyncFixture.cs b/test/Stateless.Tests/DynamicAsyncTriggerBehaviourAsyncFixture.cs new file mode 100644 index 00000000..e7551d1f --- /dev/null +++ b/test/Stateless.Tests/DynamicAsyncTriggerBehaviourAsyncFixture.cs @@ -0,0 +1,168 @@ +using System; +using System.Threading.Tasks; +using Xunit; + +namespace Stateless.Tests +{ + public class DynamicAsyncTriggerBehaviourAsyncFixture + { + [Fact] + public async Task PermitDynamic_Selects_Expected_State_Async() + { + var sm = new StateMachine(State.A); + sm.Configure(State.A) + .PermitDynamicAsync(Trigger.X, async () => { await Task.Delay(100); return State.B; }); + + await sm.FireAsync(Trigger.X); + + Assert.Equal(State.B, sm.State); + } + + [Fact] + public async Task PermitDynamic_With_TriggerParameter_Selects_Expected_State_Async() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitDynamicAsync(trigger, async (i) => { await Task.Delay(100); return i == 1 ? State.B : State.C; }); + + await sm.FireAsync(trigger, 1); + + Assert.Equal(State.B, sm.State); + } + + [Fact] + public async Task PermitDynamic_Permits_Reentry_Async() + { + var sm = new StateMachine(State.A); + var onExitInvoked = false; + var onEntryInvoked = false; + var onEntryFromInvoked = false; + sm.Configure(State.A) + .PermitDynamicAsync(Trigger.X, async () => { await Task.Delay(100); return State.A; }) + .OnEntry(() => onEntryInvoked = true) + .OnEntryFrom(Trigger.X, () => onEntryFromInvoked = true) + .OnExit(() => onExitInvoked = true); + + await sm.FireAsync(Trigger.X); + + Assert.True(onExitInvoked, "Expected OnExit to be invoked"); + Assert.True(onEntryInvoked, "Expected OnEntry to be invoked"); + Assert.True(onEntryFromInvoked, "Expected OnEntryFrom to be invoked"); + Assert.Equal(State.A, sm.State); + } + + [Fact] + public async Task PermitDynamic_Selects_Expected_State_Based_On_DestinationStateSelector_Function_Async() + { + var sm = new StateMachine(State.A); + var value = 'C'; + sm.Configure(State.A) + .PermitDynamicAsync(Trigger.X, async () => { await Task.Delay(100); return value == 'B' ? State.B : State.C; }); + + await sm.FireAsync(Trigger.X); + + Assert.Equal(State.C, sm.State); + } + + [Fact] + public async Task PermitDynamicIf_With_TriggerParameter_Permits_Transition_When_GuardCondition_Met_Async() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitDynamicIfAsync(trigger, async (i) =>{ await Task.Delay(100); return i == 1 ? State.C : State.B; }, (i) => i == 1); + + await sm.FireAsync(trigger, 1); + + Assert.Equal(State.C, sm.State); + } + + [Fact] + public async Task PermitDynamicIf_With_2_TriggerParameters_Permits_Transition_When_GuardCondition_Met_Async() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitDynamicIfAsync( + trigger, + async (i, j) => { await Task.Yield(); return i == 1 && j == 2 ? State.C : State.B; }, + (i, j) => i == 1 && j == 2); + + await sm.FireAsync(trigger, 1, 2); + + Assert.Equal(State.C, sm.State); + } + + [Fact] + public async Task PermitDynamicIf_With_3_TriggerParameters_Permits_Transition_When_GuardCondition_Met_Async() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitDynamicIfAsync( + trigger, + async (i, j, k) => { await Task.Delay(100); return i == 1 && j == 2 && k == 3 ? State.C : State.B; }, + (i, j, k) => i == 1 && j == 2 && k == 3); + + await sm.FireAsync(trigger, 1, 2, 3); + + Assert.Equal(State.C, sm.State); + } + + [Fact] + public async Task PermitDynamicIf_With_TriggerParameter_Throws_When_GuardCondition_Not_Met_Async() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitDynamicIfAsync(trigger, async (i) => { await Task.Delay(100); return i > 0 ? State.C : State.B; }, guard: (i) => i == 2); + + await Assert.ThrowsAsync(async () => await sm.FireAsync(trigger, 1)); + } + + [Fact] + public async Task PermitDynamicIf_With_2_TriggerParameters_Throws_When_GuardCondition_Not_Met_Async() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitDynamicIfAsync( + trigger, + async (i, j) => { await Task.Delay(100); return i > 0 ? State.C : State.B; }, + (i, j) => i == 2 && j == 3); + + await Assert.ThrowsAsync(async () => await sm.FireAsync(trigger, 1, 2)); + } + + [Fact] + public async Task PermitDynamicIf_With_3_TriggerParameters_Throws_When_GuardCondition_Not_Met_Async() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitDynamicIfAsync(trigger, + async (i, j, k) => { await Task.Delay(100); return i > 0 ? State.C : State.B; }, + (i, j, k) => i == 2 && j == 3 && k == 4); + + await Assert.ThrowsAsync(async () => await sm.FireAsync(trigger, 1, 2, 3)); + } + + [Fact] + public async Task PermitDynamicIf_Permits_Reentry_When_GuardCondition_Met_Async() + { + var sm = new StateMachine(State.A); + var onExitInvoked = false; + var onEntryInvoked = false; + var onEntryFromInvoked = false; + sm.Configure(State.A) + .PermitDynamicIfAsync(Trigger.X, async () =>{ await Task.Delay(100); return State.A; }, () => true) + .OnEntry(() => onEntryInvoked = true) + .OnEntryFrom(Trigger.X, () => onEntryFromInvoked = true) + .OnExit(() => onExitInvoked = true); + + await sm.FireAsync(Trigger.X); + + Assert.True(onExitInvoked, "Expected OnExit to be invoked"); + Assert.True(onEntryInvoked, "Expected OnEntry to be invoked"); + Assert.True(onEntryFromInvoked, "Expected OnEntryFrom to be invoked"); + Assert.Equal(State.A, sm.State); + } + } +} From 389f1565eb168e5d255511e02b0dddc3d468ac8d Mon Sep 17 00:00:00 2001 From: Roman Kuzmin Date: Sun, 4 Aug 2024 07:28:20 +0000 Subject: [PATCH 12/18] Escape labels in `UmlDotGraphStyle` --- src/Stateless/Graph/UmlDotGraphStyle.cs | 38 +++++++------ test/Stateless.Tests/DotGraphFixture.cs | 76 +++++++++++++++---------- 2 files changed, 69 insertions(+), 45 deletions(-) diff --git a/src/Stateless/Graph/UmlDotGraphStyle.cs b/src/Stateless/Graph/UmlDotGraphStyle.cs index 7d2f5bd0..f86d21d0 100644 --- a/src/Stateless/Graph/UmlDotGraphStyle.cs +++ b/src/Stateless/Graph/UmlDotGraphStyle.cs @@ -29,21 +29,20 @@ public override string GetPrefix() public override string FormatOneCluster(SuperState stateInfo) { string stateRepresentationString = ""; - var sourceName = stateInfo.StateName; - StringBuilder label = new StringBuilder($"{sourceName}"); + StringBuilder label = new StringBuilder($"{EscapeLabel(stateInfo.StateName)}"); if (stateInfo.EntryActions.Count > 0 || stateInfo.ExitActions.Count > 0) { label.Append("\\n----------"); - label.Append(string.Concat(stateInfo.EntryActions.Select(act => "\\nentry / " + act))); - label.Append(string.Concat(stateInfo.ExitActions.Select(act => "\\nexit / " + act))); + label.Append(string.Concat(stateInfo.EntryActions.Select(act => "\\nentry / " + EscapeLabel(act)))); + label.Append(string.Concat(stateInfo.ExitActions.Select(act => "\\nexit / " + EscapeLabel(act)))); } stateRepresentationString = "\n" - + $"subgraph \"cluster{stateInfo.NodeName}\"" + "\n" + + $"subgraph \"cluster{EscapeLabel(stateInfo.NodeName)}\"" + "\n" + "\t{" + "\n" - + $"\tlabel = \"{label.ToString()}\"" + "\n"; + + $"\tlabel = \"{label}\"" + "\n"; foreach (var subState in stateInfo.SubStates) { @@ -62,16 +61,18 @@ public override string FormatOneCluster(SuperState stateInfo) /// public override string FormatOneState(State state) { + var escapedStateName = EscapeLabel(state.StateName); + if (state.EntryActions.Count == 0 && state.ExitActions.Count == 0) - return $"\"{state.StateName}\" [label=\"{state.StateName}\"];\n"; + return $"\"{escapedStateName}\" [label=\"{escapedStateName}\"];\n"; - string f = $"\"{state.StateName}\" [label=\"{state.StateName}|"; + string f = $"\"{escapedStateName}\" [label=\"{escapedStateName}|"; List es = new List(); - es.AddRange(state.EntryActions.Select(act => "entry / " + act)); - es.AddRange(state.ExitActions.Select(act => "exit / " + act)); + es.AddRange(state.EntryActions.Select(act => "entry / " + EscapeLabel(act))); + es.AddRange(state.ExitActions.Select(act => "exit / " + EscapeLabel(act))); - f += String.Join("\\n", es); + f += string.Join("\\n", es); f += "\"];\n"; @@ -110,7 +111,7 @@ public override string FormatOneTransition(string sourceNodeName, string trigger /// public override string FormatOneDecisionNode(string nodeName, string label) { - return $"\"{nodeName}\" [shape = \"diamond\", label = \"{label}\"];\n"; + return $"\"{EscapeLabel(nodeName)}\" [shape = \"diamond\", label = \"{EscapeLabel(label)}\"];\n"; } /// @@ -121,17 +122,22 @@ public override string FormatOneDecisionNode(string nodeName, string label) public override string GetInitialTransition(StateInfo initialState) { var initialStateName = initialState.UnderlyingState.ToString(); - string dirgraphText = System.Environment.NewLine + $" init [label=\"\", shape=point];"; - dirgraphText += System.Environment.NewLine + $" init -> \"{initialStateName}\"[style = \"solid\"]"; + string dirgraphText = Environment.NewLine + $" init [label=\"\", shape=point];"; + dirgraphText += Environment.NewLine + $" init -> \"{EscapeLabel(initialStateName)}\"[style = \"solid\"]"; - dirgraphText += System.Environment.NewLine + "}"; + dirgraphText += Environment.NewLine + "}"; return dirgraphText; } internal string FormatOneLine(string fromNodeName, string toNodeName, string label) { - return $"\"{fromNodeName}\" -> \"{toNodeName}\" [style=\"solid\", label=\"{label}\"];"; + return $"\"{EscapeLabel(fromNodeName)}\" -> \"{EscapeLabel(toNodeName)}\" [style=\"solid\", label=\"{EscapeLabel(label)}\"];"; + } + + private static string EscapeLabel(string label) + { + return label.Replace("\\", "\\\\").Replace("\"", "\\\""); } } } diff --git a/test/Stateless.Tests/DotGraphFixture.cs b/test/Stateless.Tests/DotGraphFixture.cs index 70b65aa3..98cfabe1 100644 --- a/test/Stateless.Tests/DotGraphFixture.cs +++ b/test/Stateless.Tests/DotGraphFixture.cs @@ -136,19 +136,32 @@ public void SimpleTransition() } [Fact] - public void SimpleTransitionUML() + public void SimpleTransitionWithEscaping() { - var expected = Prefix(Style.UML) + Box(Style.UML, "A") + Box(Style.UML, "B") + Line("A", "B", "X") + suffix; + var state1 = "\\state \"1\""; + var state2 = "\\state \"2\""; + var trigger1 = "\\trigger \"1\""; - var sm = new StateMachine(State.A); + string suffix = Environment.NewLine + + $" init [label=\"\", shape=point];" + Environment.NewLine + + $" init -> \"{EscapeLabel(state1)}\"[style = \"solid\"]" + Environment.NewLine + + "}"; - sm.Configure(State.A) - .Permit(Trigger.X, State.B); + var expected = + Prefix(Style.UML) + + Box(Style.UML, EscapeLabel(state1)) + + Box(Style.UML, EscapeLabel(state2)) + + Line(EscapeLabel(state1), EscapeLabel(state2), EscapeLabel(trigger1)) + suffix; + + var sm = new StateMachine(state1); + + sm.Configure(state1) + .Permit(trigger1, state2); string dotGraph = UmlDotGraph.Format(sm.GetInfo()); #if WRITE_DOTS_TO_FOLDER - System.IO.File.WriteAllText(DestinationFolder + "SimpleTransitionUML.dot", dotGraph); + System.IO.File.WriteAllText(DestinationFolder + "SimpleTransitionWithEscaping.dot", dotGraph); #endif Assert.Equal(expected, dotGraph); @@ -196,7 +209,7 @@ public void WhenDiscriminatedByAnonymousGuardWithDescription() var expected = Prefix(Style.UML) + Box(Style.UML, "A") + Box(Style.UML, "B") + Line("A", "B", "X [description]") - + suffix; + + suffix; var sm = new StateMachine(State.A); @@ -398,46 +411,50 @@ public void OnEntryWithTriggerParameter() Assert.Equal(expected, dotGraph); } - + [Fact] public void SpacedUmlWithSubstate() { - string StateA = "State A"; - string StateB = "State B"; - string StateC = "State C"; - string StateD = "State D"; - string TriggerX = "Trigger X"; - string TriggerY = "Trigger Y"; - + string StateA = "State \"A\""; + string StateB = "State \"B\""; + string StateC = "State \"C\""; + string StateD = "State \"D\""; + string TriggerX = "Trigger \"X\""; + string TriggerY = "Trigger \"Y\""; + string EnterA = "Enter \"A\""; + string EnterD = "Enter \"D\""; + string ExitA = "Exit \"A\""; + var expected = Prefix(Style.UML) - + Subgraph(Style.UML, StateD, $"{StateD}\\n----------\\nentry / Enter D", - Box(Style.UML, StateB) - + Box(Style.UML, StateC)) - + Box(Style.UML, StateA, new List { "Enter A" }, new List { "Exit A" }) - + Line(StateA, StateB, TriggerX) + Line(StateA, StateC, TriggerY) - + Environment.NewLine + + Subgraph(Style.UML, EscapeLabel(StateD), $"{EscapeLabel(StateD)}\\n----------\\nentry / {EscapeLabel(EnterD)}", + Box(Style.UML, EscapeLabel(StateB)) + + Box(Style.UML, EscapeLabel(StateC))) + + Box(Style.UML, EscapeLabel(StateA), new List { EscapeLabel(EnterA) }, new List { EscapeLabel(ExitA) }) + + Line(EscapeLabel(StateA), EscapeLabel(StateB), EscapeLabel(TriggerX)) + + Line(EscapeLabel(StateA), EscapeLabel(StateC), EscapeLabel(TriggerY)) + + Environment.NewLine + $" init [label=\"\", shape=point];" + Environment.NewLine - + $" init -> \"{StateA}\"[style = \"solid\"]" + Environment.NewLine + + $" init -> \"{EscapeLabel(StateA)}\"[style = \"solid\"]" + Environment.NewLine + "}"; - var sm = new StateMachine("State A"); + var sm = new StateMachine(StateA); sm.Configure(StateA) .Permit(TriggerX, StateB) .Permit(TriggerY, StateC) - .OnEntry(TestEntryAction, "Enter A") - .OnExit(TestEntryAction, "Exit A"); + .OnEntry(TestEntryAction, EnterA) + .OnExit(TestEntryAction, ExitA); sm.Configure(StateB) .SubstateOf(StateD); sm.Configure(StateC) .SubstateOf(StateD); sm.Configure(StateD) - .OnEntry(TestEntryAction, "Enter D"); + .OnEntry(TestEntryAction, EnterD); string dotGraph = UmlDotGraph.Format(sm.GetInfo()); #if WRITE_DOTS_TO_FOLDER - System.IO.File.WriteAllText(DestinationFolder + "UmlWithSubstate.dot", dotGraph); + System.IO.File.WriteAllText(DestinationFolder + "SpacedUmlWithSubstate.dot", dotGraph); #endif Assert.Equal(expected, dotGraph); @@ -493,7 +510,7 @@ public void UmlWithDynamic() var sm = new StateMachine(State.A); sm.Configure(State.A) - .PermitDynamic(Trigger.X, DestinationSelector, null, new DynamicStateInfos { { State.B, "ChoseB"}, { State.C, "ChoseC" } }); + .PermitDynamic(Trigger.X, DestinationSelector, null, new DynamicStateInfos { { State.B, "ChoseB" }, { State.C, "ChoseC" } }); sm.Configure(State.B); sm.Configure(State.C); @@ -513,7 +530,7 @@ public void TransitionWithIgnoreAndEntry() + Box(Style.UML, "A", new List { "DoEntry" }) + Box(Style.UML, "B", new List { "DoThisEntry" }) + Line("A", "B", "X") - + Line("A", "A", "Y") + + Line("A", "A", "Y") + Line("B", "B", "Z / DoThisEntry") + suffix; @@ -644,5 +661,6 @@ public void Reentrant_Transition_Shows_Entry_Action_When_Action_Is_Configured_Wi private void TestEntryAction() { } private void TestEntryActionString(string val) { } private State DestinationSelector() { return State.A; } + private static string EscapeLabel(string label) { return label.Replace("\\", "\\\\").Replace("\"", "\\\""); } } } From 75f6cba341da06282521ee80c111a87ab77e1d22 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Mon, 5 Aug 2024 15:25:28 -0600 Subject: [PATCH 13/18] Add missing test functions * Add missing test functions * DynamicAsync was not been detected by reflection state info --- src/Stateless/Reflection/StateInfo.cs | 4 + test/Stateless.Tests/DotGraphFixture.cs | 84 ++++++++- .../DynamicAsyncTriggerBehaviourFixture.cs | 168 ++++++++++++++++++ test/Stateless.Tests/ReflectionFixture.cs | 135 ++++++++++++++ test/Stateless.Tests/StateMachineFixture.cs | 23 +++ test/Stateless.Tests/Stateless.Tests.sln | 25 +++ .../TriggerWithParametersFixture.cs | 14 ++ 7 files changed, 447 insertions(+), 6 deletions(-) create mode 100644 test/Stateless.Tests/DynamicAsyncTriggerBehaviourFixture.cs create mode 100644 test/Stateless.Tests/Stateless.Tests.sln diff --git a/src/Stateless/Reflection/StateInfo.cs b/src/Stateless/Reflection/StateInfo.cs index 340166e3..02e9fa18 100644 --- a/src/Stateless/Reflection/StateInfo.cs +++ b/src/Stateless/Reflection/StateInfo.cs @@ -72,6 +72,10 @@ internal static void AddRelationships(StateInfo info, StateMac { dynamicTransitions.Add(((StateMachine.DynamicTriggerBehaviour)item).TransitionInfo); } + foreach (var item in triggerBehaviours.Value.Where(behaviour => behaviour is StateMachine.DynamicTriggerBehaviourAsync)) + { + dynamicTransitions.Add(((StateMachine.DynamicTriggerBehaviourAsync)item).TransitionInfo); + } } info.AddRelationships(superstate, substates, fixedTransitions, dynamicTransitions); diff --git a/test/Stateless.Tests/DotGraphFixture.cs b/test/Stateless.Tests/DotGraphFixture.cs index 784c262c..b5eb2042 100644 --- a/test/Stateless.Tests/DotGraphFixture.cs +++ b/test/Stateless.Tests/DotGraphFixture.cs @@ -5,6 +5,7 @@ using Xunit; using Stateless.Reflection; using Stateless.Graph; +using System.Threading.Tasks; namespace Stateless.Tests { @@ -196,7 +197,7 @@ public void WhenDiscriminatedByAnonymousGuardWithDescription() var expected = Prefix(Style.UML) + Box(Style.UML, "A") + Box(Style.UML, "B") + Line("A", "B", "X [description]") - + suffix; + + suffix; var sm = new StateMachine(State.A); @@ -258,6 +259,27 @@ public void DestinationStateIsDynamic() string dotGraph = UmlDotGraph.Format(sm.GetInfo()); +#if WRITE_DOTS_TO_FOLDER + System.IO.File.WriteAllText(DestinationFolder + "DestinationStateIsDynamic.dot", dotGraph); +#endif + + Assert.Equal(expected, dotGraph); + } + + [Fact] + public void DestinationStateIsDynamicAsync() + { + var expected = Prefix(Style.UML) + + Box(Style.UML, "A") + + Decision(Style.UML, "Decision1", "Function") + + Line("A", "Decision1", "X") + suffix; + + var sm = new StateMachine(State.A); + sm.Configure(State.A) + .PermitDynamicAsync(Trigger.X, () => Task.FromResult(State.B)); + + string dotGraph = UmlDotGraph.Format(sm.GetInfo()); + #if WRITE_DOTS_TO_FOLDER System.IO.File.WriteAllText(DestinationFolder + "DestinationStateIsDynamic.dot", dotGraph); #endif @@ -280,6 +302,27 @@ public void DestinationStateIsCalculatedBasedOnTriggerParameters() string dotGraph = UmlDotGraph.Format(sm.GetInfo()); +#if WRITE_DOTS_TO_FOLDER + System.IO.File.WriteAllText(DestinationFolder + "DestinationStateIsCalculatedBasedOnTriggerParameters.dot", dotGraph); +#endif + Assert.Equal(expected, dotGraph); + } + + [Fact] + public void DestinationStateIsCalculatedBasedOnTriggerParametersAsync() + { + var expected = Prefix(Style.UML) + + Box(Style.UML, "A") + + Decision(Style.UML, "Decision1", "Function") + + Line("A", "Decision1", "X") + suffix; + + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitDynamicAsync(trigger, i =>Task.FromResult(i == 1 ? State.B : State.C)); + + string dotGraph = UmlDotGraph.Format(sm.GetInfo()); + #if WRITE_DOTS_TO_FOLDER System.IO.File.WriteAllText(DestinationFolder + "DestinationStateIsCalculatedBasedOnTriggerParameters.dot", dotGraph); #endif @@ -398,7 +441,7 @@ public void OnEntryWithTriggerParameter() Assert.Equal(expected, dotGraph); } - + [Fact] public void SpacedUmlWithSubstate() { @@ -408,14 +451,14 @@ public void SpacedUmlWithSubstate() string StateD = "State D"; string TriggerX = "Trigger X"; string TriggerY = "Trigger Y"; - + var expected = Prefix(Style.UML) + Subgraph(Style.UML, StateD, $"{StateD}\\n----------\\nentry / Enter D", Box(Style.UML, StateB) + Box(Style.UML, StateC)) + Box(Style.UML, StateA, new List { "Enter A" }, new List { "Exit A" }) + Line(StateA, StateB, TriggerX) + Line(StateA, StateC, TriggerY) - + Environment.NewLine + + Environment.NewLine + $" init [label=\"\", shape=point];" + Environment.NewLine + $" init -> \"{StateA}\"[style = \"solid\"]" + Environment.NewLine + "}"; @@ -493,7 +536,36 @@ public void UmlWithDynamic() var sm = new StateMachine(State.A); sm.Configure(State.A) - .PermitDynamic(Trigger.X, DestinationSelector, null, new DynamicStateInfos { { State.B, "ChoseB"}, { State.C, "ChoseC" } }); + .PermitDynamic(Trigger.X, DestinationSelector, null, new DynamicStateInfos { { State.B, "ChoseB" }, { State.C, "ChoseC" } }); + + sm.Configure(State.B); + sm.Configure(State.C); + + string dotGraph = UmlDotGraph.Format(sm.GetInfo()); +#if WRITE_DOTS_TO_FOLDER + System.IO.File.WriteAllText(DestinationFolder + "UmlWithDynamic.dot", dotGraph); +#endif + + Assert.Equal(expected, dotGraph); + } + + [Fact] + public void UmlWithDynamicAsync() + { + var expected = Prefix(Style.UML) + + Box(Style.UML, "A") + + Box(Style.UML, "B") + + Box(Style.UML, "C") + + Decision(Style.UML, "Decision1", "Function") + + Line("A", "Decision1", "X") + + Line("Decision1", "B", "X [ChoseB]") + + Line("Decision1", "C", "X [ChoseC]") + + suffix; + + var sm = new StateMachine(State.A); + + sm.Configure(State.A) + .PermitDynamicAsync(Trigger.X, () => Task.FromResult(DestinationSelector()), null, new DynamicStateInfos { { State.B, "ChoseB" }, { State.C, "ChoseC" } }); sm.Configure(State.B); sm.Configure(State.C); @@ -513,7 +585,7 @@ public void TransitionWithIgnoreAndEntry() + Box(Style.UML, "A", new List { "DoEntry" }) + Box(Style.UML, "B", new List { "DoThisEntry" }) + Line("A", "B", "X") - + Line("A", "A", "Y") + + Line("A", "A", "Y") + Line("B", "B", "Z / DoThisEntry") + suffix; diff --git a/test/Stateless.Tests/DynamicAsyncTriggerBehaviourFixture.cs b/test/Stateless.Tests/DynamicAsyncTriggerBehaviourFixture.cs new file mode 100644 index 00000000..be111418 --- /dev/null +++ b/test/Stateless.Tests/DynamicAsyncTriggerBehaviourFixture.cs @@ -0,0 +1,168 @@ +using System; +using System.Threading.Tasks; +using Xunit; + +namespace Stateless.Tests +{ + public class DynamicAsyncTriggerBehaviourFixture + { + [Fact] + public async void PermitDynamic_Selects_Expected_State() + { + var sm = new StateMachine(State.A); + sm.Configure(State.A) + .PermitDynamicAsync(Trigger.X, () => Task.FromResult(State.B)); + + await sm.FireAsync(Trigger.X); + + Assert.Equal(State.B, sm.State); + } + + [Fact] + public async void PermitDynamic_With_TriggerParameter_Selects_Expected_State() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitDynamicAsync(trigger, i => Task.FromResult(i == 1 ? State.B : State.C)); + + await sm.FireAsync(trigger, 1); + + Assert.Equal(State.B, sm.State); + } + + [Fact] + public async void PermitDynamic_Permits_Reentry() + { + var sm = new StateMachine(State.A); + var onExitInvoked = false; + var onEntryInvoked = false; + var onEntryFromInvoked = false; + sm.Configure(State.A) + .PermitDynamicAsync(Trigger.X, () => Task.FromResult(State.A)) + .OnEntry(() => onEntryInvoked = true) + .OnEntryFrom(Trigger.X, () => onEntryFromInvoked = true) + .OnExit(() => onExitInvoked = true); + + await sm.FireAsync(Trigger.X); + + Assert.True(onExitInvoked, "Expected OnExit to be invoked"); + Assert.True(onEntryInvoked, "Expected OnEntry to be invoked"); + Assert.True(onEntryFromInvoked, "Expected OnEntryFrom to be invoked"); + Assert.Equal(State.A, sm.State); + } + + [Fact] + public async void PermitDynamic_Selects_Expected_State_Based_On_DestinationStateSelector_Function() + { + var sm = new StateMachine(State.A); + var value = 'C'; + sm.Configure(State.A) + .PermitDynamicAsync(Trigger.X, () => Task.FromResult(value == 'B' ? State.B : State.C)); + + await sm.FireAsync(Trigger.X); + + Assert.Equal(State.C, sm.State); + } + + [Fact] + public async void PermitDynamicIf_With_TriggerParameter_Permits_Transition_When_GuardCondition_Met() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitDynamicIfAsync(trigger, (i) => Task.FromResult(i == 1 ? State.C : State.B), (i) => i == 1); + + await sm.FireAsync(trigger, 1); + + Assert.Equal(State.C, sm.State); + } + + [Fact] + public async void PermitDynamicIf_With_2_TriggerParameters_Permits_Transition_When_GuardCondition_Met() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitDynamicIfAsync( + trigger, + (i, j) => Task.FromResult(i == 1 && j == 2 ? State.C : State.B), + (i, j) => i == 1 && j == 2); + + await sm.FireAsync(trigger, 1, 2); + + Assert.Equal(State.C, sm.State); + } + + [Fact] + public async void PermitDynamicIf_With_3_TriggerParameters_Permits_Transition_When_GuardCondition_Met() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitDynamicIfAsync( + trigger, + (i, j, k) => Task.FromResult(i == 1 && j == 2 && k == 3 ? State.C : State.B), + (i, j, k) => i == 1 && j == 2 && k == 3); + + await sm.FireAsync(trigger, 1, 2, 3); + + Assert.Equal(State.C, sm.State); + } + + [Fact] + public void PermitDynamicIf_With_TriggerParameter_Throws_When_GuardCondition_Not_Met() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitDynamicIfAsync(trigger, (i) => Task.FromResult(i > 0 ? State.C : State.B), (i) => i == 2 ? true : false); + + Assert.Throws(() => sm.Fire(trigger, 1)); + } + + [Fact] + public void PermitDynamicIf_With_2_TriggerParameters_Throws_When_GuardCondition_Not_Met() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitDynamicIfAsync( + trigger, + (i, j) => Task.FromResult(i > 0 ? State.C : State.B), + (i, j) => i == 2 && j == 3); + + Assert.Throws(() => sm.Fire(trigger, 1, 2)); + } + + [Fact] + public void PermitDynamicIf_With_3_TriggerParameters_Throws_When_GuardCondition_Not_Met() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitDynamicIfAsync(trigger, + (i, j, k) => Task.FromResult(i > 0 ? State.C : State.B), + (i, j, k) => i == 2 && j == 3 && k == 4); + + Assert.Throws(() => sm.Fire(trigger, 1, 2, 3)); + } + + [Fact] + public async void PermitDynamicIf_Permits_Reentry_When_GuardCondition_Met() + { + var sm = new StateMachine(State.A); + var onExitInvoked = false; + var onEntryInvoked = false; + var onEntryFromInvoked = false; + sm.Configure(State.A) + .PermitDynamicIfAsync(Trigger.X, () => Task.FromResult(State.A), () => true) + .OnEntry(() => onEntryInvoked = true) + .OnEntryFrom(Trigger.X, () => onEntryFromInvoked = true) + .OnExit(() => onExitInvoked = true); + + await sm.FireAsync(Trigger.X); + + Assert.True(onExitInvoked, "Expected OnExit to be invoked"); + Assert.True(onEntryInvoked, "Expected OnEntry to be invoked"); + Assert.True(onEntryFromInvoked, "Expected OnEntryFrom to be invoked"); + Assert.Equal(State.A, sm.State); + } + } +} diff --git a/test/Stateless.Tests/ReflectionFixture.cs b/test/Stateless.Tests/ReflectionFixture.cs index eff37206..ef688823 100644 --- a/test/Stateless.Tests/ReflectionFixture.cs +++ b/test/Stateless.Tests/ReflectionFixture.cs @@ -382,6 +382,39 @@ public void DestinationStateIsDynamic_Binding() } } + [Fact] + public void DestinationStateIsDynamicAsync_Binding() + { + var sm = new StateMachine(State.A); + sm.Configure(State.A) + .PermitDynamicAsync(Trigger.X, () => Task.FromResult(State.B)); + + StateMachineInfo inf = sm.GetInfo(); + + Assert.True(inf.StateType == typeof(State)); + Assert.Equal(inf.TriggerType, typeof(Trigger)); + Assert.Equal(inf.States.Count(), 1); + var binding = inf.States.Single(s => (State)s.UnderlyingState == State.A); + + Assert.True(binding.UnderlyingState is State); + Assert.Equal(State.A, (State)binding.UnderlyingState); + // + Assert.Equal(0, binding.Substates.Count()); + Assert.Equal(null, binding.Superstate); + Assert.Equal(0, binding.EntryActions.Count()); + Assert.Equal(0, binding.ExitActions.Count()); + // + Assert.Equal(0, binding.FixedTransitions.Count()); // Binding transition count mismatch + Assert.Equal(0, binding.IgnoredTriggers.Count()); + Assert.Equal(1, binding.DynamicTransitions.Count()); // Dynamic transition count mismatch + foreach (DynamicTransitionInfo trans in binding.DynamicTransitions) + { + Assert.True(trans.Trigger.UnderlyingTrigger is Trigger); + Assert.Equal(Trigger.X, (Trigger)trans.Trigger.UnderlyingTrigger); + Assert.Equal(0, trans.GuardConditionsMethodDescriptions.Count()); + } + } + [Fact] public void DestinationStateIsCalculatedBasedOnTriggerParameters_Binding() { @@ -416,6 +449,40 @@ public void DestinationStateIsCalculatedBasedOnTriggerParameters_Binding() } } + [Fact] + public void DestinationStateIsCalculatedBasedOnTriggerParameters_BindingAsync() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitDynamicAsync(trigger, i => Task.FromResult(i == 1 ? State.B : State.C)); + + StateMachineInfo inf = sm.GetInfo(); + + Assert.True(inf.StateType == typeof(State)); + Assert.Equal(inf.TriggerType, typeof(Trigger)); + Assert.Equal(inf.States.Count(), 1); + var binding = inf.States.Single(s => (State)s.UnderlyingState == State.A); + + Assert.True(binding.UnderlyingState is State); + Assert.Equal(State.A, (State)binding.UnderlyingState); + // + Assert.Equal(0, binding.Substates.Count()); + Assert.Equal(null, binding.Superstate); + Assert.Equal(0, binding.EntryActions.Count()); + Assert.Equal(0, binding.ExitActions.Count()); + // + Assert.Equal(0, binding.FixedTransitions.Count()); // Binding transition count mismatch" + Assert.Equal(0, binding.IgnoredTriggers.Count()); + Assert.Equal(1, binding.DynamicTransitions.Count()); // Dynamic transition count mismatch + foreach (DynamicTransitionInfo trans in binding.DynamicTransitions) + { + Assert.True(trans.Trigger.UnderlyingTrigger is Trigger); + Assert.Equal(Trigger.X, (Trigger)trans.Trigger.UnderlyingTrigger); + Assert.Equal(0, trans.GuardConditionsMethodDescriptions.Count()); + } + } + [Fact] public void OnEntryWithAnonymousActionAndDescription_Binding() { @@ -893,6 +960,74 @@ StateConfiguration InternalPermitDynamic(TTrigger trigger, Func(State.A); + + sm.Configure(State.A) + .PermitIf(Trigger.X, State.B, Permit); + sm.Configure(State.B) + .PermitIf(Trigger.X, State.C, Permit, UserDescription + "B-Permit"); + sm.Configure(State.C) + .PermitIf(Trigger.X, State.B, () => Permit()); + sm.Configure(State.D) + .PermitIf(Trigger.X, State.C, () => Permit(), UserDescription + "D-Permit"); + + StateMachineInfo inf = sm.GetInfo(); + + foreach (StateInfo stateInfo in inf.States) + { + Assert.Equal(1, stateInfo.Transitions.Count()); + TransitionInfo transInfo = stateInfo.Transitions.First(); + Assert.Equal(1, transInfo.GuardConditionsMethodDescriptions.Count()); + VerifyMethodNames(transInfo.GuardConditionsMethodDescriptions, "", "Permit", (State)stateInfo.UnderlyingState, InvocationInfo.Timing.Synchronous); + } + + + // -------------------------------------------------------- + + sm = new StateMachine(State.A); + + sm.Configure(State.A) + .PermitDynamicIfAsync(Trigger.X, () => Task.FromResult(NextState()), Permit); + sm.Configure(State.B) + .PermitDynamicIfAsync(Trigger.X, () => Task.FromResult(NextState()), Permit, UserDescription + "B-Permit"); + sm.Configure(State.C) + .PermitDynamicIfAsync(Trigger.X, () => Task.FromResult(NextState()), () => Permit()); + sm.Configure(State.D) + .PermitDynamicIfAsync(Trigger.X, () => Task.FromResult(NextState()), () => Permit(), UserDescription + "D-Permit"); + + inf = sm.GetInfo(); + + foreach (StateInfo stateInfo in inf.States) + { + Assert.Equal(1, stateInfo.Transitions.Count()); + TransitionInfo transInfo = stateInfo.Transitions.First(); + Assert.Equal(1, transInfo.GuardConditionsMethodDescriptions.Count()); + VerifyMethodNames(transInfo.GuardConditionsMethodDescriptions, "", "Permit", (State)stateInfo.UnderlyingState, InvocationInfo.Timing.Synchronous); + } + + /* + public IgnoredTriggerBehaviour(TTrigger trigger, Func guard, string description = null) + : base(trigger, new TransitionGuard(guard, description)) + public InternalTriggerBehaviour(TTrigger trigger, Func guard) + : base(trigger, new TransitionGuard(guard, "Internal Transition")) + public TransitioningTriggerBehaviour(TTrigger trigger, TState destination, Func guard = null, string guardDescription = null) + : base(trigger, new TransitionGuard(guard, guardDescription)) + + public StateConfiguration PermitReentryIf(TTrigger trigger, Func guard, string guardDescription = null) + + public StateConfiguration PermitDynamicIf(TriggerWithParameters trigger, Func destinationStateSelector, Func guard, string guardDescription = null) + public StateConfiguration PermitDynamicIf(TriggerWithParameters trigger, Func destinationStateSelector, Func guard, string guardDescription = null) + public StateConfiguration PermitDynamicIf(TriggerWithParameters trigger, Func destinationStateSelector, Func guard, string guardDescription = null) + + StateConfiguration InternalPermit(TTrigger trigger, TState destinationState, string guardDescription) + StateConfiguration InternalPermitDynamic(TTrigger trigger, Func destinationStateSelector, string guardDescription) + */ + } + + [Fact] public void InvocationInfo_Description_Property_When_Method_Name_Is_Null_Returns_String_Literal_Null() { diff --git a/test/Stateless.Tests/StateMachineFixture.cs b/test/Stateless.Tests/StateMachineFixture.cs index 91cb42ac..a347b72c 100644 --- a/test/Stateless.Tests/StateMachineFixture.cs +++ b/test/Stateless.Tests/StateMachineFixture.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Xunit; namespace Stateless.Tests @@ -912,6 +913,18 @@ public void TransitionWhenPermitDyanmicIfHasMultipleExclusiveGuards() Assert.Equal(sm.State, State.B); } + [Fact] + public async void TransitionWhenPermitDyanmicIfAsyncHasMultipleExclusiveGuards() + { + var sm = new StateMachine(State.A); + var x = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitDynamicIfAsync(x, i => Task.FromResult(i == 3 ? State.B : State.C), i => i == 3 || i == 5) + .PermitDynamicIfAsync(x, i => Task.FromResult(i == 2 ? State.C : State.D), i => i == 2 || i == 4); + await sm.FireAsync(x, 3); + Assert.Equal(sm.State, State.B); + } + [Fact] public void ExceptionWhenPermitDyanmicIfHasMultipleNonExclusiveGuards() { @@ -922,6 +935,16 @@ public void ExceptionWhenPermitDyanmicIfHasMultipleNonExclusiveGuards() Assert.Throws(() => sm.Fire(x, 2)); } + [Fact] + public void ExceptionWhenPermitDyanmicIfAsyncHasMultipleNonExclusiveGuards() + { + var sm = new StateMachine(State.A); + var x = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitDynamicIfAsync(x, i => Task.FromResult(i == 4 ? State.B : State.C), i => i % 2 == 0) + .PermitDynamicIfAsync(x, i => Task.FromResult(i == 2 ? State.C : State.D), i => i == 2); + + Assert.Throws(() => sm.Fire(x, 2)); + } [Fact] public void TransitionWhenPermitIfHasMultipleExclusiveGuardsWithSuperStateTrue() diff --git a/test/Stateless.Tests/Stateless.Tests.sln b/test/Stateless.Tests/Stateless.Tests.sln new file mode 100644 index 00000000..4fc08418 --- /dev/null +++ b/test/Stateless.Tests/Stateless.Tests.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stateless.Tests", "Stateless.Tests.csproj", "{AAEE5EF9-84E2-42BB-838B-6EBA7F4825B5}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {AAEE5EF9-84E2-42BB-838B-6EBA7F4825B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AAEE5EF9-84E2-42BB-838B-6EBA7F4825B5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AAEE5EF9-84E2-42BB-838B-6EBA7F4825B5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AAEE5EF9-84E2-42BB-838B-6EBA7F4825B5}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {EC9983B3-95F4-42D0-9FFC-F3DD2B863F80} + EndGlobalSection +EndGlobal diff --git a/test/Stateless.Tests/TriggerWithParametersFixture.cs b/test/Stateless.Tests/TriggerWithParametersFixture.cs index 8d92f147..80b70cc8 100644 --- a/test/Stateless.Tests/TriggerWithParametersFixture.cs +++ b/test/Stateless.Tests/TriggerWithParametersFixture.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Xunit; namespace Stateless.Tests @@ -62,6 +63,19 @@ public void StateParameterIsNotAmbiguous() .PermitDynamicIf(pressTrigger, state => state); } + /// + /// issue #380 - default params on PermitIfDynamic lead to ambiguity at compile time... explicits work properly. + /// + [Fact] + public void StateParameterIsNotAmbiguousAsync() + { + var fsm = new StateMachine(State.A); + StateMachine.TriggerWithParameters pressTrigger = fsm.SetTriggerParameters(Trigger.X); + + fsm.Configure(State.A) + .PermitDynamicIfAsync(pressTrigger, state => Task.FromResult(state)); + } + [Fact] public void IncompatibleParameterListIsNotValid() { From 246fb2ff414d288de2c2205b2b0569699d81480b Mon Sep 17 00:00:00 2001 From: Lee Oades Date: Mon, 2 Dec 2024 21:31:47 +0000 Subject: [PATCH 14/18] Upgrade "Microsoft.NET.Test.Sdk" to 17.12.0 to remove Newtonsoft.Json and System.Net.Http versions that have been flagged as containing known high severity vulnerabilities. --- test/Stateless.Tests/Stateless.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Stateless.Tests/Stateless.Tests.csproj b/test/Stateless.Tests/Stateless.Tests.csproj index d94f9231..7f98f74d 100644 --- a/test/Stateless.Tests/Stateless.Tests.csproj +++ b/test/Stateless.Tests/Stateless.Tests.csproj @@ -20,7 +20,7 @@ - + From 49413361320615c30502a36bbad399aa1a5354c5 Mon Sep 17 00:00:00 2001 From: Lee Oades Date: Mon, 2 Dec 2024 20:45:58 +0000 Subject: [PATCH 15/18] Created a custom awaiter that guarantees that we complete on a different threadpool thread. Previously, an awaited Task had a non-zero chance of completing before it was awaited, in which case execution would continue on the same thread. --- .../SynchronizationContextFixture.cs | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/test/Stateless.Tests/SynchronizationContextFixture.cs b/test/Stateless.Tests/SynchronizationContextFixture.cs index c4f8cacd..2a745227 100644 --- a/test/Stateless.Tests/SynchronizationContextFixture.cs +++ b/test/Stateless.Tests/SynchronizationContextFixture.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -41,15 +43,16 @@ private void CaptureSyncContext() private async Task LoseSyncContext() { - await Task.Run(() => { }).ConfigureAwait(false); // Switch synchronization context and continue + await new CompletesOnDifferentThreadAwaitable(); // Switch synchronization context and continue Assert.NotEqual(_customSynchronizationContext, SynchronizationContext.Current); } /// - /// Tests capture the SynchronizationContext at various points through out their execution. - /// This asserts that every capture is the expected SynchronizationContext instance and that is hasn't been lost. + /// Tests capture the SynchronizationContext at various points throughout their execution. + /// This asserts that every capture is the expected SynchronizationContext instance and that it hasn't been lost. /// /// Ensure that we have the expected number of captures + // ReSharper disable once ParameterOnlyUsedForPreconditionCheck.Local private void AssertSyncContextAlwaysRetained(int numberOfExpectedCalls) { Assert.Equal(numberOfExpectedCalls, _capturedSyncContext.Count); @@ -154,7 +157,7 @@ public async Task Multiple_Deactivations_should_retain_SyncContext() // ASSERT AssertSyncContextAlwaysRetained(3); - } + } [Fact] public async Task Multiple_OnEntry_should_retain_SyncContext() @@ -338,4 +341,21 @@ public async Task InternalTransition_firing_a_sync_action_should_retain_SyncCont // ASSERT AssertSyncContextAlwaysRetained(1); } + + private class CompletesOnDifferentThreadAwaitable + { + public CompletesOnDifferentThreadAwaiter GetAwaiter() => new(); + + internal class CompletesOnDifferentThreadAwaiter : INotifyCompletion + { + public void GetResult() { } + + public bool IsCompleted => false; + + public void OnCompleted(Action continuation) + { + ThreadPool.QueueUserWorkItem(_ => continuation()); + } + } + } } \ No newline at end of file From fcf2b755c66efd70b799f8308d030e36216bca8d Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Mon, 23 Dec 2024 21:51:49 +0000 Subject: [PATCH 16/18] Merge from dev --- src/Stateless/Graph/UmlDotGraphStyle.cs | 38 +++++++++++-------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/src/Stateless/Graph/UmlDotGraphStyle.cs b/src/Stateless/Graph/UmlDotGraphStyle.cs index 9a2b5c43..0e465681 100644 --- a/src/Stateless/Graph/UmlDotGraphStyle.cs +++ b/src/Stateless/Graph/UmlDotGraphStyle.cs @@ -7,12 +7,12 @@ namespace Stateless.Graph { /// - /// Generate DOT graphs in basic UML style + /// Generate DOT graphs in basic UML style. /// public class UmlDotGraphStyle : GraphStyleBase { - /// Get the text that starts a new graph - /// + /// Get the text that starts a new graph. + /// The prefix for the DOT graph document. public override string GetPrefix() { var sb = new StringBuilder(); @@ -26,10 +26,9 @@ public override string GetPrefix() /// /// Returns the formatted text for a single superstate and its substates. - /// For example, for DOT files this would be a subgraph containing nodes for all the substates. /// - /// The superstate to generate text for - /// Description of the superstate, and all its substates, in the desired format + /// A DOT graph representation of the superstate and all its substates. + /// public override string FormatOneCluster(SuperState stateInfo) { var sb = new StringBuilder(); @@ -60,10 +59,10 @@ public override string FormatOneCluster(SuperState stateInfo) } /// - /// Generate the text for a single state + /// Generate the text for a single state. /// - /// The state to generate text for - /// + /// A DOT graph representation of the state. + /// public override string FormatOneState(State state) { var escapedStateName = EscapeLabel(state.StateName); @@ -85,14 +84,10 @@ public override string FormatOneState(State state) } /// - /// Generate text for a single transition + /// Generate text for a single transition. /// - /// - /// - /// - /// - /// - /// + /// A DOT graph representation of a state transition. + /// public override string FormatOneTransition(string sourceNodeName, string trigger, IEnumerable actions, string destinationNodeName, IEnumerable guards) { string label = trigger ?? ""; @@ -116,19 +111,18 @@ public override string FormatOneTransition(string sourceNodeName, string trigger /// /// Generate the text for a single decision node /// - /// Name of the node - /// Label for the node - /// + /// A DOT graph representation of the decision node for a dynamic transition. + /// public override string FormatOneDecisionNode(string nodeName, string label) { return $"\"{EscapeLabel(nodeName)}\" [shape = \"diamond\", label = \"{EscapeLabel(label)}\"];{Environment.NewLine}"; } /// - /// + /// Get initial transition if present. /// - /// - /// + /// A DOT graph representation of the initial state transition. + /// public override string GetInitialTransition(StateInfo initialState) { var initialStateName = initialState.UnderlyingState.ToString(); From 08f5abd26db41f56d5d6dfa5db2e6c2a6c71d5aa Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Sun, 29 Dec 2024 14:44:24 +0000 Subject: [PATCH 17/18] Add net9.0 to build targets; include build targets in tests. --- src/Stateless/Stateless.csproj | 2 +- test/Stateless.Tests/GetInfoFixture.cs | 59 +- test/Stateless.Tests/StateInfoTests.cs | 35 +- test/Stateless.Tests/Stateless.Tests.csproj | 2 +- .../SynchronizationContextFixture.cs | 579 +++++++++--------- 5 files changed, 340 insertions(+), 337 deletions(-) diff --git a/src/Stateless/Stateless.csproj b/src/Stateless/Stateless.csproj index 8abca638..98e83cf6 100644 --- a/src/Stateless/Stateless.csproj +++ b/src/Stateless/Stateless.csproj @@ -4,7 +4,7 @@ Stateless Stateless Stateless - netstandard2.0;net462;net8.0; + netstandard2.0;net462;net8.0;net9.0 Create state machines and lightweight state machine-based workflows directly in .NET code Copyright © Stateless Contributors 2009-$([System.DateTime]::Now.ToString(yyyy)) en-US diff --git a/test/Stateless.Tests/GetInfoFixture.cs b/test/Stateless.Tests/GetInfoFixture.cs index d8a77825..570ee914 100644 --- a/test/Stateless.Tests/GetInfoFixture.cs +++ b/test/Stateless.Tests/GetInfoFixture.cs @@ -1,41 +1,42 @@ using System.Threading.Tasks; using Xunit; -namespace Stateless.Tests; - -public class GetInfoFixture +namespace Stateless.Tests { - [Fact] - public void GetInfo_should_return_Entry_action_with_trigger_name() + public class GetInfoFixture { - // ARRANGE - var sm = new StateMachine(State.A); - sm.Configure(State.B) - .OnEntryFrom(Trigger.X, () => { }); + [Fact] + public void GetInfo_should_return_Entry_action_with_trigger_name() + { + // ARRANGE + var sm = new StateMachine(State.A); + sm.Configure(State.B) + .OnEntryFrom(Trigger.X, () => { }); - // ACT - var stateMachineInfo = sm.GetInfo(); + // ACT + var stateMachineInfo = sm.GetInfo(); - // ASSERT - var stateInfo = Assert.Single(stateMachineInfo.States); - var entryActionInfo = Assert.Single(stateInfo.EntryActions); - Assert.Equal(Trigger.X.ToString(), entryActionInfo.FromTrigger); - } + // ASSERT + var stateInfo = Assert.Single(stateMachineInfo.States); + var entryActionInfo = Assert.Single(stateInfo.EntryActions); + Assert.Equal(Trigger.X.ToString(), entryActionInfo.FromTrigger); + } - [Fact] - public void GetInfo_should_return_async_Entry_action_with_trigger_name() - { - // ARRANGE - var sm = new StateMachine(State.A); - sm.Configure(State.B) - .OnEntryFromAsync(Trigger.X, () => Task.CompletedTask); + [Fact] + public void GetInfo_should_return_async_Entry_action_with_trigger_name() + { + // ARRANGE + var sm = new StateMachine(State.A); + sm.Configure(State.B) + .OnEntryFromAsync(Trigger.X, () => Task.CompletedTask); - // ACT - var stateMachineInfo = sm.GetInfo(); + // ACT + var stateMachineInfo = sm.GetInfo(); - // ASSERT - var stateInfo = Assert.Single(stateMachineInfo.States); - var entryActionInfo = Assert.Single(stateInfo.EntryActions); - Assert.Equal(Trigger.X.ToString(), entryActionInfo.FromTrigger); + // ASSERT + var stateInfo = Assert.Single(stateMachineInfo.States); + var entryActionInfo = Assert.Single(stateInfo.EntryActions); + Assert.Equal(Trigger.X.ToString(), entryActionInfo.FromTrigger); + } } } \ No newline at end of file diff --git a/test/Stateless.Tests/StateInfoTests.cs b/test/Stateless.Tests/StateInfoTests.cs index edd8f33e..320cd8db 100644 --- a/test/Stateless.Tests/StateInfoTests.cs +++ b/test/Stateless.Tests/StateInfoTests.cs @@ -1,25 +1,26 @@ using Stateless.Reflection; using Xunit; -namespace Stateless.Tests; - -public class StateInfoTests +namespace Stateless.Tests { - /// - /// For StateInfo, Substates, FixedTransitions and DynamicTransitions are only initialised by a call to AddRelationships. - /// However, for StateMachineInfo.InitialState, this never happens. Therefore StateMachineInfo.InitialState.Transitions - /// throws a System.ArgumentNullException. - /// - [Fact] - public void StateInfo_transitions_should_default_to_empty() + public class StateInfoTests { - // ARRANGE - var stateInfo = StateInfo.CreateStateInfo(new StateMachine.StateRepresentation(State.A)); + /// + /// For StateInfo, Substates, FixedTransitions and DynamicTransitions are only initialised by a call to AddRelationships. + /// However, for StateMachineInfo.InitialState, this never happens. Therefore StateMachineInfo.InitialState.Transitions + /// throws a System.ArgumentNullException. + /// + [Fact] + public void StateInfo_transitions_should_default_to_empty() + { + // ARRANGE + var stateInfo = StateInfo.CreateStateInfo(new StateMachine.StateRepresentation(State.A)); - // ACT - var stateInfoTransitions = stateInfo.Transitions; + // ACT + var stateInfoTransitions = stateInfo.Transitions; - // ASSERT - Assert.Null(stateInfoTransitions); - } + // ASSERT + Assert.Null(stateInfoTransitions); + } + } } \ No newline at end of file diff --git a/test/Stateless.Tests/Stateless.Tests.csproj b/test/Stateless.Tests/Stateless.Tests.csproj index 7f98f74d..2eed06a8 100644 --- a/test/Stateless.Tests/Stateless.Tests.csproj +++ b/test/Stateless.Tests/Stateless.Tests.csproj @@ -3,7 +3,7 @@ $(DefineConstants);TASKS true - net8.0 + net462;net8.0;net9.0 false Stateless.Tests ../../asset/Stateless.snk diff --git a/test/Stateless.Tests/SynchronizationContextFixture.cs b/test/Stateless.Tests/SynchronizationContextFixture.cs index 2a745227..43633aaf 100644 --- a/test/Stateless.Tests/SynchronizationContextFixture.cs +++ b/test/Stateless.Tests/SynchronizationContextFixture.cs @@ -6,355 +6,356 @@ using Xunit; using Xunit.Sdk; -namespace Stateless.Tests; - -public class SynchronizationContextFixture +namespace Stateless.Tests { - // Define a custom SynchronizationContext. All calls made to delegates should be with this context. - private readonly MaxConcurrencySyncContext _customSynchronizationContext = new(3); - private readonly List _capturedSyncContext = new(); - - private StateMachine GetSut(State initialState = State.A) + public class SynchronizationContextFixture { - return new StateMachine(initialState, FiringMode.Queued) + // Define a custom SynchronizationContext. All calls made to delegates should be with this context. + private readonly MaxConcurrencySyncContext _customSynchronizationContext = new MaxConcurrencySyncContext(3); + private readonly List _capturedSyncContext = new List(); + + private StateMachine GetSut(State initialState = State.A) { - RetainSynchronizationContext = true - }; - } + return new StateMachine(initialState, FiringMode.Queued) + { + RetainSynchronizationContext = true + }; + } - private void SetSyncContext() - { - SynchronizationContext.SetSynchronizationContext(_customSynchronizationContext); - } + private void SetSyncContext() + { + SynchronizationContext.SetSynchronizationContext(_customSynchronizationContext); + } - /// - /// Simulate a call that loses the synchronization context - /// - private async Task CaptureThenLoseSyncContext() - { - CaptureSyncContext(); - await LoseSyncContext().ConfigureAwait(false); // ConfigureAwait false here to ensure we continue using the sync context returned by LoseSyncContext - } + /// + /// Simulate a call that loses the synchronization context + /// + private async Task CaptureThenLoseSyncContext() + { + CaptureSyncContext(); + await LoseSyncContext().ConfigureAwait(false); // ConfigureAwait false here to ensure we continue using the sync context returned by LoseSyncContext + } - private void CaptureSyncContext() - { - _capturedSyncContext.Add(SynchronizationContext.Current); - } + private void CaptureSyncContext() + { + _capturedSyncContext.Add(SynchronizationContext.Current); + } - private async Task LoseSyncContext() - { - await new CompletesOnDifferentThreadAwaitable(); // Switch synchronization context and continue - Assert.NotEqual(_customSynchronizationContext, SynchronizationContext.Current); - } + private async Task LoseSyncContext() + { + await new CompletesOnDifferentThreadAwaitable(); // Switch synchronization context and continue + Assert.NotEqual(_customSynchronizationContext, SynchronizationContext.Current); + } - /// - /// Tests capture the SynchronizationContext at various points throughout their execution. - /// This asserts that every capture is the expected SynchronizationContext instance and that it hasn't been lost. - /// - /// Ensure that we have the expected number of captures - // ReSharper disable once ParameterOnlyUsedForPreconditionCheck.Local - private void AssertSyncContextAlwaysRetained(int numberOfExpectedCalls) - { - Assert.Equal(numberOfExpectedCalls, _capturedSyncContext.Count); - Assert.All(_capturedSyncContext, actual => Assert.Equal(_customSynchronizationContext, actual)); - } + /// + /// Tests capture the SynchronizationContext at various points throughout their execution. + /// This asserts that every capture is the expected SynchronizationContext instance and that it hasn't been lost. + /// + /// Ensure that we have the expected number of captures + // ReSharper disable once ParameterOnlyUsedForPreconditionCheck.Local + private void AssertSyncContextAlwaysRetained(int numberOfExpectedCalls) + { + Assert.Equal(numberOfExpectedCalls, _capturedSyncContext.Count); + Assert.All(_capturedSyncContext, actual => Assert.Equal(_customSynchronizationContext, actual)); + } - /// - /// XUnit uses its own SynchronizationContext to execute each test. Therefore, placing SetSyncContext() in the constructor instead of - /// at the start of every test does not work as desired. This test ensures XUnit's behaviour has not changed. - /// - [Fact] - public void Ensure_XUnit_is_using_SyncContext() - { - SetSyncContext(); - CaptureSyncContext(); - AssertSyncContextAlwaysRetained(1); - } + /// + /// XUnit uses its own SynchronizationContext to execute each test. Therefore, placing SetSyncContext() in the constructor instead of + /// at the start of every test does not work as desired. This test ensures XUnit's behaviour has not changed. + /// + [Fact] + public void Ensure_XUnit_is_using_SyncContext() + { + SetSyncContext(); + CaptureSyncContext(); + AssertSyncContextAlwaysRetained(1); + } - /// - /// SynchronizationContext are funny things. The way that they are lost varies depending on their implementation. - /// This test ensures that our mechanism for losing the SynchronizationContext works. - /// - [Fact] - public async Task Ensure_XUnit_can_lose_sync_context() - { - SetSyncContext(); - await LoseSyncContext().ConfigureAwait(false); - Assert.NotEqual(_customSynchronizationContext, SynchronizationContext.Current); - } + /// + /// SynchronizationContext are funny things. The way that they are lost varies depending on their implementation. + /// This test ensures that our mechanism for losing the SynchronizationContext works. + /// + [Fact] + public async Task Ensure_XUnit_can_lose_sync_context() + { + SetSyncContext(); + await LoseSyncContext().ConfigureAwait(false); + Assert.NotEqual(_customSynchronizationContext, SynchronizationContext.Current); + } - [Fact] - public async Task Activation_of_state_with_superstate_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A) - .OnActivateAsync(CaptureThenLoseSyncContext) - .SubstateOf(State.B); + [Fact] + public async Task Activation_of_state_with_superstate_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .OnActivateAsync(CaptureThenLoseSyncContext) + .SubstateOf(State.B); - sm.Configure(State.B) - .OnActivateAsync(CaptureThenLoseSyncContext); + sm.Configure(State.B) + .OnActivateAsync(CaptureThenLoseSyncContext); - // ACT - await sm.ActivateAsync(); + // ACT + await sm.ActivateAsync(); - // ASSERT - AssertSyncContextAlwaysRetained(2); - } + // ASSERT + AssertSyncContextAlwaysRetained(2); + } - [Fact] - public async Task Deactivation_of_state_with_superstate_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A) - .OnDeactivateAsync(CaptureThenLoseSyncContext) - .SubstateOf(State.B); + [Fact] + public async Task Deactivation_of_state_with_superstate_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .OnDeactivateAsync(CaptureThenLoseSyncContext) + .SubstateOf(State.B); - sm.Configure(State.B) - .OnDeactivateAsync(CaptureThenLoseSyncContext); + sm.Configure(State.B) + .OnDeactivateAsync(CaptureThenLoseSyncContext); - // ACT - await sm.DeactivateAsync(); + // ACT + await sm.DeactivateAsync(); - // ASSERT - AssertSyncContextAlwaysRetained(2); - } + // ASSERT + AssertSyncContextAlwaysRetained(2); + } - [Fact] - public async Task Multiple_activations_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A) - .OnActivateAsync(CaptureThenLoseSyncContext) - .OnActivateAsync(CaptureThenLoseSyncContext) - .OnActivateAsync(CaptureThenLoseSyncContext); + [Fact] + public async Task Multiple_activations_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .OnActivateAsync(CaptureThenLoseSyncContext) + .OnActivateAsync(CaptureThenLoseSyncContext) + .OnActivateAsync(CaptureThenLoseSyncContext); - // ACT - await sm.ActivateAsync(); + // ACT + await sm.ActivateAsync(); - // ASSERT - AssertSyncContextAlwaysRetained(3); - } + // ASSERT + AssertSyncContextAlwaysRetained(3); + } - [Fact] - public async Task Multiple_Deactivations_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A) - .OnDeactivateAsync(CaptureThenLoseSyncContext) - .OnDeactivateAsync(CaptureThenLoseSyncContext) - .OnDeactivateAsync(CaptureThenLoseSyncContext); + [Fact] + public async Task Multiple_Deactivations_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .OnDeactivateAsync(CaptureThenLoseSyncContext) + .OnDeactivateAsync(CaptureThenLoseSyncContext) + .OnDeactivateAsync(CaptureThenLoseSyncContext); - // ACT - await sm.DeactivateAsync(); + // ACT + await sm.DeactivateAsync(); - // ASSERT - AssertSyncContextAlwaysRetained(3); - } + // ASSERT + AssertSyncContextAlwaysRetained(3); + } - [Fact] - public async Task Multiple_OnEntry_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A).Permit(Trigger.X, State.B); - sm.Configure(State.B) - .OnEntryAsync(CaptureThenLoseSyncContext) - .OnEntryAsync(CaptureThenLoseSyncContext) - .OnEntryAsync(CaptureThenLoseSyncContext); + [Fact] + public async Task Multiple_OnEntry_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A).Permit(Trigger.X, State.B); + sm.Configure(State.B) + .OnEntryAsync(CaptureThenLoseSyncContext) + .OnEntryAsync(CaptureThenLoseSyncContext) + .OnEntryAsync(CaptureThenLoseSyncContext); - // ACT - await sm.FireAsync(Trigger.X); + // ACT + await sm.FireAsync(Trigger.X); - // ASSERT - AssertSyncContextAlwaysRetained(3); - } + // ASSERT + AssertSyncContextAlwaysRetained(3); + } - [Fact] - public async Task Multiple_OnExit_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A) - .Permit(Trigger.X, State.B) - .OnExitAsync(CaptureThenLoseSyncContext) - .OnExitAsync(CaptureThenLoseSyncContext) - .OnExitAsync(CaptureThenLoseSyncContext); - sm.Configure(State.B); + [Fact] + public async Task Multiple_OnExit_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .Permit(Trigger.X, State.B) + .OnExitAsync(CaptureThenLoseSyncContext) + .OnExitAsync(CaptureThenLoseSyncContext) + .OnExitAsync(CaptureThenLoseSyncContext); + sm.Configure(State.B); - // ACT - await sm.FireAsync(Trigger.X); + // ACT + await sm.FireAsync(Trigger.X); - // ASSERT - AssertSyncContextAlwaysRetained(3); - } + // ASSERT + AssertSyncContextAlwaysRetained(3); + } - [Fact] - public async Task OnExit_state_with_superstate_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(State.B); - sm.Configure(State.A) - .OnExitAsync(CaptureThenLoseSyncContext) - ; + [Fact] + public async Task OnExit_state_with_superstate_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(State.B); + sm.Configure(State.A) + .OnExitAsync(CaptureThenLoseSyncContext) + ; - sm.Configure(State.B) - .SubstateOf(State.A) - .Permit(Trigger.X, State.C) - .OnExitAsync(CaptureThenLoseSyncContext) - ; - sm.Configure(State.C); + sm.Configure(State.B) + .SubstateOf(State.A) + .Permit(Trigger.X, State.C) + .OnExitAsync(CaptureThenLoseSyncContext) + ; + sm.Configure(State.C); - // ACT - await sm.FireAsync(Trigger.X); + // ACT + await sm.FireAsync(Trigger.X); - // ASSERT - AssertSyncContextAlwaysRetained(2); - } + // ASSERT + AssertSyncContextAlwaysRetained(2); + } - [Fact] - public async Task OnExit_state_and_superstate_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(State.C); - sm.Configure(State.A); + [Fact] + public async Task OnExit_state_and_superstate_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(State.C); + sm.Configure(State.A); - sm.Configure(State.B) - .SubstateOf(State.A) - .OnExitAsync(CaptureThenLoseSyncContext); + sm.Configure(State.B) + .SubstateOf(State.A) + .OnExitAsync(CaptureThenLoseSyncContext); - sm.Configure(State.C) - .SubstateOf(State.B) - .Permit(Trigger.X, State.A) - .OnExitAsync(CaptureThenLoseSyncContext); + sm.Configure(State.C) + .SubstateOf(State.B) + .Permit(Trigger.X, State.A) + .OnExitAsync(CaptureThenLoseSyncContext); - // ACT - await sm.FireAsync(Trigger.X); + // ACT + await sm.FireAsync(Trigger.X); - // ASSERT - AssertSyncContextAlwaysRetained(2); - } + // ASSERT + AssertSyncContextAlwaysRetained(2); + } - [Fact] - public async Task Multiple_OnEntry_on_Reentry_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A).PermitReentry(Trigger.X) - .OnEntryAsync(CaptureThenLoseSyncContext) - .OnEntryAsync(CaptureThenLoseSyncContext); + [Fact] + public async Task Multiple_OnEntry_on_Reentry_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A).PermitReentry(Trigger.X) + .OnEntryAsync(CaptureThenLoseSyncContext) + .OnEntryAsync(CaptureThenLoseSyncContext); - // ACT - await sm.FireAsync(Trigger.X); + // ACT + await sm.FireAsync(Trigger.X); - // ASSERT - AssertSyncContextAlwaysRetained(2); - } + // ASSERT + AssertSyncContextAlwaysRetained(2); + } - [Fact] - public async Task Multiple_OnExit_on_Reentry_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A).PermitReentry(Trigger.X) - .OnExitAsync(CaptureThenLoseSyncContext) - .OnExitAsync(CaptureThenLoseSyncContext); + [Fact] + public async Task Multiple_OnExit_on_Reentry_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A).PermitReentry(Trigger.X) + .OnExitAsync(CaptureThenLoseSyncContext) + .OnExitAsync(CaptureThenLoseSyncContext); - // ACT - await sm.FireAsync(Trigger.X); + // ACT + await sm.FireAsync(Trigger.X); - // ASSERT - AssertSyncContextAlwaysRetained(2); - } + // ASSERT + AssertSyncContextAlwaysRetained(2); + } - [Fact] - public async Task Trigger_firing_another_Trigger_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A) - .InternalTransitionAsync(Trigger.X, async () => - { - await CaptureThenLoseSyncContext(); - await sm.FireAsync(Trigger.Y); - }) - .Permit(Trigger.Y, State.B) - ; - sm.Configure(State.B) - .OnEntryAsync(CaptureThenLoseSyncContext); + [Fact] + public async Task Trigger_firing_another_Trigger_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .InternalTransitionAsync(Trigger.X, async () => + { + await CaptureThenLoseSyncContext(); + await sm.FireAsync(Trigger.Y); + }) + .Permit(Trigger.Y, State.B) + ; + sm.Configure(State.B) + .OnEntryAsync(CaptureThenLoseSyncContext); - // ACT - await sm.FireAsync(Trigger.X); + // ACT + await sm.FireAsync(Trigger.X); - // ASSERT - AssertSyncContextAlwaysRetained(2); - } + // ASSERT + AssertSyncContextAlwaysRetained(2); + } - [Fact] - public async Task OnTransition_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A) - .Permit(Trigger.X, State.B); + [Fact] + public async Task OnTransition_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .Permit(Trigger.X, State.B); - sm.Configure(State.B); + sm.Configure(State.B); - sm.OnTransitionedAsync(_ => CaptureThenLoseSyncContext()); - sm.OnTransitionedAsync(_ => CaptureThenLoseSyncContext()); - sm.OnTransitionedAsync(_ => CaptureThenLoseSyncContext()); + sm.OnTransitionedAsync(_ => CaptureThenLoseSyncContext()); + sm.OnTransitionedAsync(_ => CaptureThenLoseSyncContext()); + sm.OnTransitionedAsync(_ => CaptureThenLoseSyncContext()); - // ACT - await sm.FireAsync(Trigger.X); + // ACT + await sm.FireAsync(Trigger.X); - // ASSERT - AssertSyncContextAlwaysRetained(3); - } + // ASSERT + AssertSyncContextAlwaysRetained(3); + } - [Fact] - public async Task InternalTransition_firing_a_sync_action_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A) - .InternalTransition(Trigger.X, CaptureSyncContext); + [Fact] + public async Task InternalTransition_firing_a_sync_action_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .InternalTransition(Trigger.X, CaptureSyncContext); - // ACT - await sm.FireAsync(Trigger.X); + // ACT + await sm.FireAsync(Trigger.X); - // ASSERT - AssertSyncContextAlwaysRetained(1); - } + // ASSERT + AssertSyncContextAlwaysRetained(1); + } - private class CompletesOnDifferentThreadAwaitable - { - public CompletesOnDifferentThreadAwaiter GetAwaiter() => new(); - - internal class CompletesOnDifferentThreadAwaiter : INotifyCompletion + private class CompletesOnDifferentThreadAwaitable { - public void GetResult() { } - - public bool IsCompleted => false; + public CompletesOnDifferentThreadAwaiter GetAwaiter() => new CompletesOnDifferentThreadAwaiter(); - public void OnCompleted(Action continuation) + internal class CompletesOnDifferentThreadAwaiter : INotifyCompletion { - ThreadPool.QueueUserWorkItem(_ => continuation()); + public void GetResult() { } + + public bool IsCompleted => false; + + public void OnCompleted(Action continuation) + { + ThreadPool.QueueUserWorkItem(_ => continuation()); + } } } } From 2684fdd97e7ded44ec8d7b335c51c792177c488a Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Sun, 29 Dec 2024 14:59:57 +0000 Subject: [PATCH 18/18] Prepare v5.17.0. --- CHANGELOG.md | 20 ++++++++++++++++++++ README.md | 29 +++++++++++++++++++++++------ src/Stateless/Stateless.csproj | 2 +- src/Stateless/docs/README.md | 2 +- 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f103ce9..6014a9fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## 5.17.0 - 2024.12.30 +### Changed + - Use `PackageLicenseExpression` in csproj file [#583], [#584] +### Added + - Added mermaid graph support [#585] + - Allow PermitDynamic destination state to be calculated with an async function (Task) [#595] + - Updated readme to clarify re-entry behaviour of dynamic transitions [#604] + - Added .NET 9.0 to build targets [#610] +### Fixed + - Unexpected graph labels for internal transitions [#587] + - Labels not escaped in `UmlDotGraphStyle` [#597] + ## 5.16.0 - 2024.05.24 ### Changed - Permit state reentry from dynamic transitions [#565] @@ -222,6 +234,14 @@ Version 5.10.0 is now listed as the newest, since it has the highest version num ### Removed ### Fixed +[#610]: https://github.com/dotnet-state-machine/stateless/pull/610 +[#604]: https://github.com/dotnet-state-machine/stateless/issues/604 +[#597]: https://github.com/dotnet-state-machine/stateless/pull/597 +[#595]: https://github.com/dotnet-state-machine/stateless/pull/595 +[#587]: https://github.com/dotnet-state-machine/stateless/pull/589 +[#585]: https://github.com/dotnet-state-machine/stateless/issues/585 +[#584]: https://github.com/dotnet-state-machine/stateless/pull/584 +[#583]: https://github.com/dotnet-state-machine/stateless/pull/583 [#575]: https://github.com/dotnet-state-machine/stateless/pull/575 [#574]: https://github.com/dotnet-state-machine/stateless/pull/574 [#570]: https://github.com/dotnet-state-machine/stateless/pull/570 diff --git a/README.md b/README.md index 0025c002..7a115c40 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ Trigger parameters can be used to dynamically select the destination state using ### Ignored Transitions and Reentrant States -Firing a trigger that does not have an allowed transition associated with it will cause an exception to be thrown. +In Stateless, firing a trigger that does not have an allowed transition associated with it will cause an exception to be thrown. This ensures that all transitions are explicitly defined, preventing unintended state changes. To ignore triggers within certain states, use the `Ignore(TTrigger)` directive: @@ -153,7 +153,7 @@ phoneCall.Configure(State.Connected) .Ignore(Trigger.CallDialled); ``` -Alternatively, a state can be marked reentrant so its entry and exit actions will fire even when transitioning from/to itself: +Alternatively, a state can be marked reentrant. A reentrant state is one that can transition back into itself. In such cases, the state's exit and entry actions will be executed, providing a way to handle events that require the state to reset or reinitialize. ```csharp stateMachine.Configure(State.Assigned) @@ -167,6 +167,23 @@ By default, triggers must be ignored explicitly. To override Stateless's default stateMachine.OnUnhandledTrigger((state, trigger) => { }); ``` +### Dynamic State Transitions and State Re-entry + +Dynamic state transitions allow the destination state to be determined at runtime based on trigger parameters or other logic. + +```csharp +stateMachine.Configure(State.Start) + .PermitDynamic(Trigger.CheckScore, () => score < 10 ? State.LowScore : State.HighScore); +``` + +When a dynamic transition results in the same state as the current state, it effectively becomes a reentrant transition, causing the state's exit and entry actions to execute. This can be useful for scenarios where the state needs to refresh or reset based on certain triggers. + +```csharp +stateMachine.Configure(State.Waiting) + .OnEntry(() => Console.WriteLine($"Elapsed time: {elapsed} seconds...")) + .PermitDynamic(Trigger.CheckStatus, () => ready ? State.Done : State.Waiting); +``` + ### State change notifications (events) Stateless supports 2 types of state machine events: @@ -183,7 +200,7 @@ This event will be invoked every time the state machine changes state. ```csharp stateMachine.OnTransitionCompleted((transition) => { }); ``` -This event will be invoked at the very end of the trigger handling, after the last entry action has been executed. +This event will be invoked at the very end of the trigger handling, after the last entry action has been executed. ### Export to DOT graph @@ -207,9 +224,9 @@ digraph { This can then be rendered by tools that support the DOT graph language, such as the [dot command line tool](http://www.graphviz.org/doc/info/command.html) from [graphviz.org](http://www.graphviz.org) or [viz.js](https://github.com/mdaines/viz.js). See http://www.webgraphviz.com for instant gratification. Command line example: `dot -T pdf -o phoneCall.pdf phoneCall.dot` to generate a PDF file. -### Export to mermaid graph +### Export to Mermaid graph -It can be useful to visualize state machines on runtime. With this approach the code is the authoritative source and state diagrams are by-products which are always up to date. +Mermaid graphs can also be generated from state machines. ```csharp phoneCall.Configure(State.OffHook) @@ -226,7 +243,7 @@ stateDiagram-v2 OffHook --> Ringing : CallDialled ``` -This can then be rendered by GitHub or [Obsidian](https://github.com/obsidianmd) +This can be rendered by GitHub markdown or an engine such as [Obsidian](https://github.com/obsidianmd). ``` mermaid stateDiagram-v2 diff --git a/src/Stateless/Stateless.csproj b/src/Stateless/Stateless.csproj index 98e83cf6..a0fba466 100644 --- a/src/Stateless/Stateless.csproj +++ b/src/Stateless/Stateless.csproj @@ -8,7 +8,7 @@ Create state machines and lightweight state machine-based workflows directly in .NET code Copyright © Stateless Contributors 2009-$([System.DateTime]::Now.ToString(yyyy)) en-US - 5.16.0 + 5.17.0 Stateless Contributors true true diff --git a/src/Stateless/docs/README.md b/src/Stateless/docs/README.md index 52d6a74a..496d4789 100644 --- a/src/Stateless/docs/README.md +++ b/src/Stateless/docs/README.md @@ -41,7 +41,7 @@ Some useful extensions are also provided: * Ability to store state externally (for example, in a property tracked by an ORM) * Parameterised triggers * Reentrant states - * Export to DOT graph + * Export to DOT and Mermaid graph ## Documentation