Skip to content

Commit

Permalink
Merge pull request #611 from dotnet-state-machine/dev
Browse files Browse the repository at this point in the history
Release 5.17.0
mclift authored Dec 30, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
2 parents a8f169d + 2684fdd commit 0ccb80c
Showing 29 changed files with 2,446 additions and 404 deletions.
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
51 changes: 48 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -41,6 +41,7 @@ Some useful extensions are also provided:
* Parameterised triggers
* Reentrant states
* Export to DOT graph
* Export to mermaid graph

### Hierarchical States

@@ -143,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:

@@ -152,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)
@@ -166,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:
@@ -182,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

@@ -206,6 +224,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

Mermaid graphs can also be generated from state machines.

```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 be rendered by GitHub markdown or an engine such as [Obsidian](https://github.com/obsidianmd).

``` mermaid
stateDiagram-v2
[*] --> OffHook
OffHook --> Ringing : CallDialled
```

### Async triggers

On platforms that provide `Task<T>`, the `StateMachine` supports `async` entry/exit actions and so on:
27 changes: 27 additions & 0 deletions src/Stateless/DynamicTriggerBehaviour.Async.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System;
using System.Threading.Tasks;

namespace Stateless
{
public partial class StateMachine<TState, TTrigger>
{
internal class DynamicTriggerBehaviourAsync : TriggerBehaviour
{
readonly Func<object[], Task<TState>> _destination;
internal Reflection.DynamicTransitionInfo TransitionInfo { get; private set; }

public DynamicTriggerBehaviourAsync(TTrigger trigger, Func<object[], Task<TState>> 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<TState> GetDestinationState(TState source, object[] args)
{
return await _destination(args);
}
}
}
}
10 changes: 2 additions & 8 deletions src/Stateless/Graph/GraphStyleBase.cs
Original file line number Diff line number Diff line change
@@ -77,17 +77,11 @@ public virtual List<string> FormatAllTransitions(List<Transition> 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
26 changes: 26 additions & 0 deletions src/Stateless/Graph/MermaidGraph.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using Stateless.Reflection;
using System.Collections;

namespace Stateless.Graph
{
/// <summary>
/// Class to generate a MermaidGraph
/// </summary>
public static class MermaidGraph
{
/// <summary>
/// Generate a Mermaid graph from the state machine info
/// </summary>
/// <param name="machineInfo"></param>
/// <param name="direction">
/// When set, includes a <c>direction</c> setting in the output indicating the direction of flow.
/// </param>
/// <returns></returns>
public static string Format(StateMachineInfo machineInfo, MermaidGraphDirection? direction = null)
{
var graph = new StateGraph(machineInfo);

return graph.ToGraph(new MermaidGraphStyle(graph, direction));
}
}
}
17 changes: 17 additions & 0 deletions src/Stateless/Graph/MermaidGraphDirection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Stateless.Graph
{
/// <summary>
/// The directions of flow that can be chosen for a Mermaid graph.
/// </summary>
public enum MermaidGraphDirection
{
/// <summary>Left-to-right flow</summary>
LeftToRight,
/// <summary>Right-to-left flow</summary>
RightToLeft,
/// <summary>Top-to-bottom flow</summary>
TopToBottom,
/// <summary>Bottom-to-top flow</summary>
BottomToTop
}
}
173 changes: 173 additions & 0 deletions src/Stateless/Graph/MermaidGraphStyle.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
using Stateless.Reflection;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace Stateless.Graph
{
/// <summary>
/// Class to generate a graph in mermaid format
/// </summary>
public class MermaidGraphStyle : GraphStyleBase
{
private readonly StateGraph _graph;
private readonly MermaidGraphDirection? _direction;
private readonly Dictionary<string, State> _stateMap = new Dictionary<string, State>();
private bool _stateMapInitialized = false;

/// <summary>
/// Create a new instance of <see cref="MermaidGraphStyle"/>
/// </summary>
/// <param name="graph">The state graph</param>
/// <param name="direction">When non-null, sets the flow direction in the output.</param>
public MermaidGraphStyle(StateGraph graph, MermaidGraphDirection? direction)
: base()
{
_graph = graph;
_direction = direction;
}

/// <inheritdoc/>
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();
}

/// <summary>
/// Generate the text for a single decision node
/// </summary>
/// <param name="nodeName">Name of the node</param>
/// <param name="label">Label for the node</param>
/// <returns></returns>
public override string FormatOneDecisionNode(string nodeName, string label)
{
return $"{Environment.NewLine}\tstate {nodeName} <<choice>>";
}

/// <inheritdoc/>
public override string FormatOneState(State state)
{
return string.Empty;
}

/// <summary>Get the text that starts a new graph</summary>
/// <returns></returns>
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;
}

/// <inheritdoc/>
public override string GetInitialTransition(StateInfo initialState)
{
var sanitizedStateName = GetSanitizedStateName(initialState.ToString());

return $"{Environment.NewLine}[*] --> {sanitizedStateName}";
}

/// <inheritdoc/>
public override string FormatOneTransition(string sourceNodeName, string trigger, IEnumerable<string> actions, string destinationNodeName, IEnumerable<string> 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<string>();
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;
}
}
}
Loading

0 comments on commit 0ccb80c

Please sign in to comment.