Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release 5.17.0 #611

Merged
merged 31 commits into from
Dec 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c1f602e
Add mermaid graph format
sulmar Dec 20, 2023
13840d6
Added export to mermaid graph
sulmar Dec 20, 2023
84a7d9c
Add export description to Mermaid
sulmar Dec 20, 2023
c81534a
Use license expression
lg2de Jun 14, 2024
d48f568
Merge pull request #583 from lg2de/patch-1
mclift Jun 14, 2024
c924b88
Remove duplicate PackageLicenseExpression
lg2de Jun 14, 2024
d58e33b
Merge pull request #584 from lg2de/fix-duplicate
mclift Jun 14, 2024
d6131a6
Merge pull request #559 from sulmar/add-mermaid-graph-style
mclift Jun 20, 2024
3760d58
Mermaid graph features: graph direction; support state names with spa…
mclift Jun 21, 2024
48ce60a
Fix entry function being shown on internal transitions in dot graph o…
mclift Jul 3, 2024
c896d66
Merge pull request #589 from mclift/bugfix/dotgraph-internal-transitions
mclift Jul 7, 2024
5483b5b
Show state entry function in re-entry transition in dot graph
mclift Jul 7, 2024
6b2cb9f
Test dot graph output for reentrant transition without parameterized …
mclift Jul 7, 2024
6c0811b
Allow async PermitDynamic
Jon2G Jul 15, 2024
e233236
Add and run tests
Jon2G Jul 16, 2024
7223486
Merge pull request #590 from mclift/bugfix/reentry-transition-missing…
mclift Jul 22, 2024
389f156
Escape labels in `UmlDotGraphStyle`
nightroman Aug 4, 2024
75f6cba
Add missing test functions
Jon2G Aug 5, 2024
6007331
Merge remote-tracking branch 'upstream/dev' into dev
Jon2G Aug 5, 2024
246fb2f
Upgrade "Microsoft.NET.Test.Sdk" to 17.12.0 to remove Newtonsoft.Json…
leeoades Dec 2, 2024
4941336
Created a custom awaiter that guarantees that we complete on a differ…
leeoades Dec 2, 2024
94867a0
Merge pull request #606 from leeoades/bugfix/really-guarantee-sync-co…
crozone Dec 3, 2024
a05ed25
Merge pull request #595 from Jon2G/dev
mclift Dec 23, 2024
800849e
Merge branch 'dev' into dev
mclift Dec 23, 2024
a128944
Merge pull request #597 from nightroman/dev
mclift Dec 23, 2024
54a0342
Merge from dev
mclift Dec 23, 2024
fcf2b75
Merge from dev
mclift Dec 23, 2024
95096e5
Merge pull request #586 from dotnet-state-machine/feature/add-mermaid…
mclift Dec 24, 2024
08f5abd
Add net9.0 to build targets; include build targets in tests.
mclift Dec 29, 2024
d3b8b09
Merge pull request #610 from mclift/add-dotnet9-target
mclift Dec 29, 2024
2684fdd
Prepare v5.17.0.
mclift Dec 29, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
51 changes: 48 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Some useful extensions are also provided:
* Parameterised triggers
* Reentrant states
* Export to DOT graph
* Export to mermaid graph

### Hierarchical States

Expand Down Expand Up @@ -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:

Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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

Expand All @@ -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:
Expand Down
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
Expand Up @@ -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
Expand Down
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