diff --git a/README.md b/README.md index 19636b1f..0025c002 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 @@ -206,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: diff --git a/src/Stateless/Graph/MermaidGraph.cs b/src/Stateless/Graph/MermaidGraph.cs new file mode 100644 index 00000000..c3d99fff --- /dev/null +++ b/src/Stateless/Graph/MermaidGraph.cs @@ -0,0 +1,26 @@ +using Stateless.Reflection; +using System.Collections; + +namespace Stateless.Graph +{ + /// + /// Class to generate a MermaidGraph + /// + 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, MermaidGraphDirection? direction = null) + { + var graph = new StateGraph(machineInfo); + + 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 new file mode 100644 index 00000000..7080c080 --- /dev/null +++ b/src/Stateless/Graph/MermaidGraphStyle.cs @@ -0,0 +1,173 @@ +using Stateless.Reflection; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Stateless.Graph +{ + /// + /// Class to generate a graph in mermaid format + /// + public class MermaidGraphStyle : GraphStyleBase + { + private readonly StateGraph _graph; + private readonly MermaidGraphDirection? _direction; + private readonly Dictionary _stateMap = new Dictionary(); + private bool _stateMapInitialized = false; + + /// + /// Create a new instance of + /// + /// 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) + { + 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(); + } + + /// + /// 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 $"{Environment.NewLine}\tstate {nodeName} <>"; + } + + /// + public override string FormatOneState(State state) + { + return string.Empty; + } + + /// Get the text that starts a new graph + /// + public override string GetPrefix() + { + 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) + { + 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 ?? ""; + + 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 0e460201..7f335f68 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 f86d21d0..0e465681 100644 --- a/src/Stateless/Graph/UmlDotGraphStyle.cs +++ b/src/Stateless/Graph/UmlDotGraphStyle.cs @@ -15,10 +15,13 @@ public class UmlDotGraphStyle : GraphStyleBase /// The prefix for the DOT graph document. 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(); } /// @@ -28,30 +31,31 @@ public override string GetPrefix() /// public override string FormatOneCluster(SuperState stateInfo) { - string stateRepresentationString = ""; + var sb = new StringBuilder(); + var sourceName = stateInfo.StateName; StringBuilder label = new StringBuilder($"{EscapeLabel(stateInfo.StateName)}"); - 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 / " + EscapeLabel(act)))); label.Append(string.Concat(stateInfo.ExitActions.Select(act => "\\nexit / " + EscapeLabel(act)))); } - stateRepresentationString = "\n" - + $"subgraph \"cluster{EscapeLabel(stateInfo.NodeName)}\"" + "\n" - + "\t{" + "\n" - + $"\tlabel = \"{label}\"" + "\n"; + sb.AppendLine() + .AppendLine($"subgraph \"cluster{EscapeLabel(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(); } /// @@ -64,7 +68,7 @@ public override string FormatOneState(State state) var escapedStateName = EscapeLabel(state.StateName); if (state.EntryActions.Count == 0 && state.ExitActions.Count == 0) - return $"\"{escapedStateName}\" [label=\"{escapedStateName}\"];\n"; + return $"\"{escapedStateName}\" [label=\"{escapedStateName}\"];{Environment.NewLine}"; string f = $"\"{escapedStateName}\" [label=\"{escapedStateName}|"; @@ -74,7 +78,7 @@ public override string FormatOneState(State state) f += string.Join("\\n", es); - f += "\"];\n"; + f += $"\"];{Environment.NewLine}"; return f; } @@ -105,13 +109,13 @@ 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. /// public override string FormatOneDecisionNode(string nodeName, string label) { - return $"\"{EscapeLabel(nodeName)}\" [shape = \"diamond\", label = \"{EscapeLabel(label)}\"];\n"; + return $"\"{EscapeLabel(nodeName)}\" [shape = \"diamond\", label = \"{EscapeLabel(label)}\"];{Environment.NewLine}"; } /// diff --git a/test/Stateless.Tests/DotGraphFixture.cs b/test/Stateless.Tests/DotGraphFixture.cs index 1c66fd95..ce6a78b0 100644 --- a/test/Stateless.Tests/DotGraphFixture.cs +++ b/test/Stateless.Tests/DotGraphFixture.cs @@ -332,7 +332,7 @@ public void DestinationStateIsCalculatedBasedOnTriggerParametersAsync() 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)); + .PermitDynamicAsync(trigger, i => Task.FromResult(i == 1 ? State.B : State.C)); string dotGraph = UmlDotGraph.Format(sm.GetInfo()); diff --git a/test/Stateless.Tests/MermaidGraphFixture.cs b/test/Stateless.Tests/MermaidGraphFixture.cs new file mode 100644 index 00000000..02042ee5 --- /dev/null +++ b/test/Stateless.Tests/MermaidGraphFixture.cs @@ -0,0 +1,406 @@ +using System.Text; +using Xunit; + +namespace Stateless.Tests +{ + public class MermaidGraphFixture + { + [Fact] + public void Format_InitialTransition_ShouldReturns() + { + var expected = new StringBuilder() + .AppendLine("stateDiagram-v2") + .AppendLine("[*] --> A") + .ToString().TrimEnd(); + + var sm = new StateMachine(State.A); + + var result = Graph.MermaidGraph.Format(sm.GetInfo()); + + WriteToFile(nameof(Format_InitialTransition_ShouldReturns), result); + + Assert.Equal(expected, result); + } + + [Fact] + public void SimpleTransition() + { + var expected = new StringBuilder() + .AppendLine("stateDiagram-v2") + .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()); + + 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 = new StringBuilder() + .AppendLine("stateDiagram-v2") + .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); + + 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 + } + } +}