diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3329882..ce892c8 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -37,6 +37,24 @@ "/consoleloggerparameters:NoSummary" ], "problemMatcher": "$msCompile" + }, + { + "label": "test", + "command": "dotnet", + "type": "shell", + "args": [ + "test" + ], + "group": "test", + "problemMatcher": "$msCompile", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + } } ] } \ No newline at end of file diff --git a/src/Liminality/DiagramWriter.cs b/src/Liminality/DiagramWriter.cs new file mode 100644 index 0000000..54fe98d --- /dev/null +++ b/src/Liminality/DiagramWriter.cs @@ -0,0 +1,59 @@ +using System.Text; + +namespace PSIBR.Liminality +{ + public abstract class DiagramWriter + where TStateMachine : StateMachine + { + public DiagramWriter(Graph graph!!) + { + Graph = graph; + } + + protected readonly Graph Graph; + public abstract Diagram Write(); + + public abstract void WriteNode(Diagram diagram, GraphNode rootNode); + } + + public abstract class Diagram + { + private StringBuilder _stringBuilder = new StringBuilder(); + public abstract void AddTransition(GraphNode? left, GraphNode? right); + public virtual void AddSyntaxLine(string syntax) + { + _stringBuilder.AppendLine(syntax); + } + + public virtual string Render() + { + return _stringBuilder.ToString(); + } + } + + public class MermaidStateDiagramWriter : DiagramWriter + where TStateMachine : StateMachine + { + public MermaidStateDiagramWriter(Graph graph) + : base(graph) + { + } + + public override Diagram Write() + { + var diagram = new MermaidStateDiagram(); + diagram.AddTransition(null, Graph); + WriteNode(diagram, Graph); + return diagram; + } + + public override void WriteNode(Diagram diagram!!, GraphNode rootNode!!) + { + foreach (var node in rootNode) + { + diagram.AddTransition(rootNode, node); + WriteNode(diagram, node); + } + } + } +} diff --git a/src/Liminality/Extensions.cs b/src/Liminality/Extensions.cs new file mode 100644 index 0000000..c03730d --- /dev/null +++ b/src/Liminality/Extensions.cs @@ -0,0 +1,19 @@ +namespace PSIBR.Liminality +{ + public static class Extensions + { + public static Graph GetGraph(this TStateMachine stateMachine!!) + where TStateMachine : StateMachine + { + var builder = new GraphBuilder(stateMachine); + return builder.Build(); + } + + public static Diagram GetDiagram(this Graph graph!!) + where TStateMachine : StateMachine + { + var writer = new MermaidStateDiagramWriter(graph); + return writer.Write(); + } + } +} diff --git a/src/Liminality/GraphBuilder.cs b/src/Liminality/GraphBuilder.cs new file mode 100644 index 0000000..99a42bf --- /dev/null +++ b/src/Liminality/GraphBuilder.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace PSIBR.Liminality +{ + public class GraphBuilder + where TStateMachine : StateMachine + { + public GraphBuilder(TStateMachine stateMachine!!) + { + _stateMachine = stateMachine; + _graph = new Graph(_stateMachine); + } + + private readonly TStateMachine _stateMachine; + private readonly Graph _graph; + private readonly Dictionary _nodeTypeCache = new(); + + public Graph Build() + { + var rootNode = new GraphNode(_stateMachine.Definition.StateMap.InitialState); + foreach (var stateMap in _stateMachine.Definition.StateMap) + { + var input = stateMap.Key; + var transition = stateMap.Value; + + //Enables us to continue node transit mapping + //in many to many situations + var subNode = GetOrCreateSubNode(rootNode, input.CurrentStateType, input.SignalType); + var goesToNode = CreateNode(transition.NewStateType); + subNode.Add(goesToNode); + } + + _graph.Add(rootNode); + + return _graph; + } + + private GraphNode GetOrCreateSubNode(GraphNode rootNode, Type type, Type? signalType) + { + //If we know about this, return so we can continue + //mapping the transit + if (_nodeTypeCache.ContainsKey(type.Name)) + return _nodeTypeCache[type.Name]; + //If we don't know about this but the root + //and the type are the same, this is a mapping + //of initial state -> something else + else if (rootNode.Name == type.Name && rootNode.Condition == signalType?.Name) + return rootNode; + + var node = CreateNode(type, signalType); + rootNode.Add(node); + return node; + } + + private GraphNode CreateNode(Type type) + { + var node = new GraphNode(type, null); + CacheNodeType(node); + return node; + } + + private GraphNode CreateNode(Type type!!, Type? signalType) + { + var node = new GraphNode(type, signalType); + CacheNodeType(node); + return node; + } + + private void CacheNodeType(GraphNode graphNode!!) + { + var key = MakeCacheKey(graphNode); + if (!_nodeTypeCache.ContainsKey(key)) + _nodeTypeCache[key] = graphNode; + } + + private static string MakeCacheKey(GraphNode graphNode!!) + { + var key = $"{graphNode.Name} {(graphNode.Condition is not null ? $":{graphNode.Condition}" : string.Empty)}"; + return key; + } + + private static string MakeCacheKey(Type type, Type? signalType) + { + var key = $"{type.Name} {(signalType is not null ? $":{signalType.Name}" : string.Empty)}"; + return key; + } + } + + [DebuggerDisplay("Name = {Name}, Goes to: {Count} other node(s)")] + public class Graph : GraphNode + where TStateMachine : StateMachine + { + private readonly TStateMachine _stateMachine; + + public Graph(TStateMachine stateMachine!!) + : base(stateMachine.GetType().Name) + { + _stateMachine = stateMachine; + } + } + + [DebuggerDisplay("Name = {Name}, Goes to: {Count} other node(s) when signaled with {Condition}")] + public class GraphNode : List + { + public GraphNode(Type type!!) + : this(type.Name, null) + { + } + + public GraphNode(Type type!!, Type? signalType) + : this(type.Name, signalType?.Name) + { + } + + public GraphNode(string name!!) + : this(name, null) + { + } + + public GraphNode(string name!!, string? condition) + { + Name = name; + Condition = condition; + } + + public string Name { get; set; } + public string? Condition { get; set; } + } +} diff --git a/src/Liminality/Liminality.csproj b/src/Liminality/Liminality.csproj index de4b4b9..ff8a055 100644 --- a/src/Liminality/Liminality.csproj +++ b/src/Liminality/Liminality.csproj @@ -27,12 +27,12 @@ - - + + - + diff --git a/src/Liminality/MermaidStateDiagram.cs b/src/Liminality/MermaidStateDiagram.cs new file mode 100644 index 0000000..43bcaf8 --- /dev/null +++ b/src/Liminality/MermaidStateDiagram.cs @@ -0,0 +1,28 @@ +namespace PSIBR.Liminality +{ + public class MermaidStateDiagram : Diagram + { + private const string DiagramTypeToken = "stateDiagram-v2"; + private const string Indent = " "; + private const string GoesToToken = "-->"; + private const string InitialStateToken = "[*]"; + + public MermaidStateDiagram() + { + AddSyntaxLine($"{DiagramTypeToken}"); + } + + public override void AddTransition(GraphNode? left, GraphNode? right) + { + string leftSyntax = left is null ? InitialStateToken : left.Name; + string rightSyntax = right is null ? InitialStateToken : right.Name; + string signalSyntax = left?.Condition is not null ? $":{left.Condition}" : string.Empty; + + if (leftSyntax != rightSyntax && !string.IsNullOrWhiteSpace(signalSyntax)) + { + var syntax = $"{Indent}{leftSyntax} {GoesToToken} {rightSyntax}{signalSyntax}"; + AddSyntaxLine(syntax); + } + } + } +} diff --git a/test/Liminality.Tests/BasicStateMachine.cs b/test/Liminality.Tests/BasicStateMachine.cs index f8373ae..4704673 100644 --- a/test/Liminality.Tests/BasicStateMachine.cs +++ b/test/Liminality.Tests/BasicStateMachine.cs @@ -44,5 +44,6 @@ public class Finished { } // Inputs public class Start { } public class Finish { } + public class Cancel { } } } \ No newline at end of file diff --git a/test/Liminality.Tests/ContainerTests.cs b/test/Liminality.Tests/DependencyTests.cs similarity index 97% rename from test/Liminality.Tests/ContainerTests.cs rename to test/Liminality.Tests/DependencyTests.cs index f56c105..3b9f74c 100644 --- a/test/Liminality.Tests/ContainerTests.cs +++ b/test/Liminality.Tests/DependencyTests.cs @@ -9,7 +9,7 @@ namespace PSIBR.Liminality.Tests { using static BasicStateMachine; - public class ContainerTests + public class DependencyTests { [Fact] public void CanResolveStateMachine() diff --git a/test/Liminality.Tests/Fixtures/BasicStateMachineFixture.cs b/test/Liminality.Tests/Fixtures/BasicStateMachineFixture.cs new file mode 100644 index 0000000..6dcfa6a --- /dev/null +++ b/test/Liminality.Tests/Fixtures/BasicStateMachineFixture.cs @@ -0,0 +1,33 @@ +using Lamar; +using Microsoft.Extensions.DependencyInjection; +using System; +using static PSIBR.Liminality.Tests.BasicStateMachine; + +namespace PSIBR.Liminality.Tests.Fixtures +{ + public class BasicStateMachineFixture : IDisposable + { + public BasicStateMachineFixture() + { + var container = new Container(x => + { + x.AddStateMachineDependencies(builder => builder + .StartsIn() + .For().On().MoveTo() + .For().On().MoveTo() + .For().On().MoveTo() + .Build()); + + x.AddTransient(); + }); + + BasicStateMachine = container.GetService() ?? throw new Exception("Container not properly setup"); + } + + public BasicStateMachine BasicStateMachine { get; } + + public void Dispose() + { + } + } +} diff --git a/test/Liminality.Tests/VisualizationTests.cs b/test/Liminality.Tests/VisualizationTests.cs new file mode 100644 index 0000000..c41163d --- /dev/null +++ b/test/Liminality.Tests/VisualizationTests.cs @@ -0,0 +1,38 @@ +using PSIBR.Liminality.Tests.Fixtures; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace PSIBR.Liminality +{ + public class VisualizationTests : IClassFixture + { + public VisualizationTests(BasicStateMachineFixture fixture) + { + Fixture = fixture; + } + + protected readonly BasicStateMachineFixture Fixture; + + [Fact] + public void Creating_Graph_Succeeds() + { + Fixture.BasicStateMachine.GetGraph(); + } + + [Fact] + public void Creating_Diagram_Succeeds() + { + var graph = Fixture.BasicStateMachine.GetGraph(); + Assert.NotNull(graph); + var diagram = graph.GetDiagram(); + Assert.NotNull(diagram); + var render = diagram.Render(); + Assert.NotNull(render); + } + + } +}