From c1f602e189f7460e4cba024c5d24cd33c92f1ef0 Mon Sep 17 00:00:00 2001 From: Marcin Sulecki Date: Wed, 20 Dec 2023 15:47:34 +0100 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 3760d58d6977cbb6bad9a79e9d5908d862f57d94 Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Fri, 21 Jun 2024 16:31:15 +0100 Subject: [PATCH 4/5] 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 fcf2b755c66efd70b799f8308d030e36216bca8d Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Mon, 23 Dec 2024 21:51:49 +0000 Subject: [PATCH 5/5] 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();