diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f103ce9..6014a9fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 19636b1f..7a115c40 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 @@ -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`, the `StateMachine` supports `async` entry/exit actions and so on: diff --git a/src/Stateless/DynamicTriggerBehaviour.Async.cs b/src/Stateless/DynamicTriggerBehaviour.Async.cs new file mode 100644 index 00000000..6f3ad8dc --- /dev/null +++ b/src/Stateless/DynamicTriggerBehaviour.Async.cs @@ -0,0 +1,27 @@ +using System; +using System.Threading.Tasks; + +namespace Stateless +{ + public partial class StateMachine + { + internal class DynamicTriggerBehaviourAsync : TriggerBehaviour + { + readonly Func> _destination; + internal Reflection.DynamicTransitionInfo TransitionInfo { get; private set; } + + public DynamicTriggerBehaviourAsync(TTrigger trigger, Func> 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 GetDestinationState(TState source, object[] args) + { + return await _destination(args); + } + } + } +} diff --git a/src/Stateless/Graph/GraphStyleBase.cs b/src/Stateless/Graph/GraphStyleBase.cs index f7139c6a..0b79c5df 100644 --- a/src/Stateless/Graph/GraphStyleBase.cs +++ b/src/Stateless/Graph/GraphStyleBase.cs @@ -77,17 +77,11 @@ public virtual List FormatAllTransitions(List 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 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 3477bfd4..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 @@ -137,10 +136,20 @@ void AddTransitions(StateMachineInfo machineInfo) State toState = States[fix.DestinationState.UnderlyingState.ToString()]; if (fromState == toState) { - StayTransition stay = new StayTransition(fromState, fix.Trigger, fix.GuardConditionsMethodDescriptions, true); + StayTransition stay = new StayTransition(fromState, fix.Trigger, fix.GuardConditionsMethodDescriptions, !fix.IsInternalTransition); Transitions.Add(stay); fromState.Leaving.Add(stay); fromState.Arriving.Add(stay); + + // If the reentrant transition causes the state's entry action to be executed, this is shown + // explicity in the state graph by adding it to the DestinationEntryActions list. + if (stay.ExecuteEntryExitActions) + { + foreach (var action in stateInfo.EntryActions.Where(a => a.FromTrigger is null)) + { + stay.DestinationEntryActions.Add(action); + } + } } else { diff --git a/src/Stateless/Graph/UmlDotGraphStyle.cs b/src/Stateless/Graph/UmlDotGraphStyle.cs index 7d2f5bd0..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,31 +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($"{sourceName}"); + 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 / " + act))); - label.Append(string.Concat(stateInfo.ExitActions.Select(act => "\\nexit / " + act))); + 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{stateInfo.NodeName}\"" + "\n" - + "\t{" + "\n" - + $"\tlabel = \"{label.ToString()}\"" + "\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(); } /// @@ -62,18 +65,20 @@ public override string FormatOneCluster(SuperState stateInfo) /// public override string FormatOneState(State state) { + var escapedStateName = EscapeLabel(state.StateName); + if (state.EntryActions.Count == 0 && state.ExitActions.Count == 0) - return $"\"{state.StateName}\" [label=\"{state.StateName}\"];\n"; + return $"\"{escapedStateName}\" [label=\"{escapedStateName}\"];{Environment.NewLine}"; - string f = $"\"{state.StateName}\" [label=\"{state.StateName}|"; + string f = $"\"{escapedStateName}\" [label=\"{escapedStateName}|"; List es = new List(); - es.AddRange(state.EntryActions.Select(act => "entry / " + act)); - es.AddRange(state.ExitActions.Select(act => "exit / " + act)); + es.AddRange(state.EntryActions.Select(act => "entry / " + EscapeLabel(act))); + es.AddRange(state.ExitActions.Select(act => "exit / " + EscapeLabel(act))); - f += String.Join("\\n", es); + f += string.Join("\\n", es); - f += "\"];\n"; + f += $"\"];{Environment.NewLine}"; return f; } @@ -104,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 $"\"{nodeName}\" [shape = \"diamond\", label = \"{label}\"];\n"; + return $"\"{EscapeLabel(nodeName)}\" [shape = \"diamond\", label = \"{EscapeLabel(label)}\"];{Environment.NewLine}"; } /// @@ -121,17 +126,22 @@ public override string FormatOneDecisionNode(string nodeName, string label) public override string GetInitialTransition(StateInfo initialState) { var initialStateName = initialState.UnderlyingState.ToString(); - string dirgraphText = System.Environment.NewLine + $" init [label=\"\", shape=point];"; - dirgraphText += System.Environment.NewLine + $" init -> \"{initialStateName}\"[style = \"solid\"]"; + string dirgraphText = Environment.NewLine + $" init [label=\"\", shape=point];"; + dirgraphText += Environment.NewLine + $" init -> \"{EscapeLabel(initialStateName)}\"[style = \"solid\"]"; - dirgraphText += System.Environment.NewLine + "}"; + dirgraphText += Environment.NewLine + "}"; return dirgraphText; } internal string FormatOneLine(string fromNodeName, string toNodeName, string label) { - return $"\"{fromNodeName}\" -> \"{toNodeName}\" [style=\"solid\", label=\"{label}\"];"; + return $"\"{EscapeLabel(fromNodeName)}\" -> \"{EscapeLabel(toNodeName)}\" [style=\"solid\", label=\"{EscapeLabel(label)}\"];"; + } + + private static string EscapeLabel(string label) + { + return label.Replace("\\", "\\\\").Replace("\"", "\\\""); } } } diff --git a/src/Stateless/Reflection/FixedTransitionInfo.cs b/src/Stateless/Reflection/FixedTransitionInfo.cs index 0aed3ec9..60275476 100644 --- a/src/Stateless/Reflection/FixedTransitionInfo.cs +++ b/src/Stateless/Reflection/FixedTransitionInfo.cs @@ -15,7 +15,8 @@ internal static FixedTransitionInfo Create(StateMachine() : behaviour.Guard.Conditions.Select(c => c.MethodDescription) + ? new List() : behaviour.Guard.Conditions.Select(c => c.MethodDescription), + IsInternalTransition = behaviour is StateMachine.InternalTriggerBehaviour }; return transition; diff --git a/src/Stateless/Reflection/StateInfo.cs b/src/Stateless/Reflection/StateInfo.cs index 340166e3..02e9fa18 100644 --- a/src/Stateless/Reflection/StateInfo.cs +++ b/src/Stateless/Reflection/StateInfo.cs @@ -72,6 +72,10 @@ internal static void AddRelationships(StateInfo info, StateMac { dynamicTransitions.Add(((StateMachine.DynamicTriggerBehaviour)item).TransitionInfo); } + foreach (var item in triggerBehaviours.Value.Where(behaviour => behaviour is StateMachine.DynamicTriggerBehaviourAsync)) + { + dynamicTransitions.Add(((StateMachine.DynamicTriggerBehaviourAsync)item).TransitionInfo); + } } info.AddRelationships(superstate, substates, fixedTransitions, dynamicTransitions); diff --git a/src/Stateless/Reflection/TransitionInfo.cs b/src/Stateless/Reflection/TransitionInfo.cs index 4cd53321..75ef1367 100644 --- a/src/Stateless/Reflection/TransitionInfo.cs +++ b/src/Stateless/Reflection/TransitionInfo.cs @@ -17,5 +17,10 @@ public abstract class TransitionInfo /// Returns a non-null but empty list if there are no guard conditions /// public IEnumerable GuardConditionsMethodDescriptions; + + /// + /// When true, the transition is internal and does not invoke the entry/exit actions of the state. + /// + public bool IsInternalTransition { get; protected set; } } } diff --git a/src/Stateless/StateConfiguration.Async.cs b/src/Stateless/StateConfiguration.Async.cs index 53a8c918..6dafc717 100644 --- a/src/Stateless/StateConfiguration.Async.cs +++ b/src/Stateless/StateConfiguration.Async.cs @@ -1,6 +1,7 @@ #if TASKS using System; +using System.Linq; using System.Threading.Tasks; namespace Stateless @@ -522,6 +523,561 @@ public StateConfiguration OnExitAsync(Func exitAction, string Reflection.InvocationInfo.Create(exitAction, exitActionDescription, Reflection.InvocationInfo.Timing.Asynchronous)); return this; } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Async function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Optional description for the async function to calculate the state + /// Optional array of possible destination states (used by output formatters) + /// The receiver. + public StateConfiguration PermitDynamicAsync(TTrigger trigger, Func> destinationStateSelector, + string destinationStateSelectorDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + _representation.AddTriggerBehaviour( + new DynamicTriggerBehaviourAsync(trigger, + args => destinationStateSelector(), + null, // No transition guard + Reflection.DynamicTransitionInfo.Create(trigger, + null, // No guards + Reflection.InvocationInfo.Create(destinationStateSelector, destinationStateSelectorDescription), + possibleDestinationStates + ) + )); + return this; + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Optional description of the function to calculate the state + /// Optional list of possible target states. + /// The receiver. + /// Type of the first trigger argument. + public StateConfiguration PermitDynamicAsync(TriggerWithParameters trigger, Func> destinationStateSelector, + string destinationStateSelectorDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + + _representation.AddTriggerBehaviour( + new DynamicTriggerBehaviourAsync(trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0)), + null, // No transition guards + Reflection.DynamicTransitionInfo.Create(trigger.Trigger, + null, // No guards + Reflection.InvocationInfo.Create(destinationStateSelector, destinationStateSelectorDescription), + possibleDestinationStates) + )); + return this; + + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Function that must return true in order for the + /// trigger to be accepted. + /// Guard description + /// Optional list of possible target states. + /// The receiver. + public StateConfiguration PermitDynamicIfAsync(TTrigger trigger, Func> destinationStateSelector, + Func guard, string guardDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + return PermitDynamicIfAsync(trigger, destinationStateSelector, null, guard, guardDescription, possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Description of the function to calculate the state + /// Function that must return true in order for the + /// trigger to be accepted. + /// Guard description + /// Optional list of possible target states. + /// The receiver. + public StateConfiguration PermitDynamicIfAsync(TTrigger trigger, Func> destinationStateSelector, + string destinationStateSelectorDescription, Func guard, string guardDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger, + args => destinationStateSelector(), + destinationStateSelectorDescription, + new TransitionGuard(guard, guardDescription), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Functions and their descriptions that must return true in order for the + /// trigger to be accepted. + /// Optional list of possible target states. + /// The receiver. + public StateConfiguration PermitDynamicIfAsync(TTrigger trigger, Func> destinationStateSelector, Reflection.DynamicStateInfos possibleDestinationStates = null, params Tuple, string>[] guards) + { + return PermitDynamicIfAsync(trigger, destinationStateSelector, null, possibleDestinationStates, guards); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Description of the function to calculate the state + /// Functions and their descriptions that must return true in order for the + /// trigger to be accepted. + /// Optional list of possible target states. + /// The receiver. + public StateConfiguration PermitDynamicIfAsync(TTrigger trigger, Func> destinationStateSelector, + string destinationStateSelectorDescription, Reflection.DynamicStateInfos possibleDestinationStates = null, params Tuple, string>[] guards) + { + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger, + args => destinationStateSelector(), + destinationStateSelectorDescription, + new TransitionGuard(guards), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Function that must return true in order for the + /// trigger to be accepted. + /// Guard description + /// Optional list of possible target states. + /// The receiver. + /// Type of the first trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Func guard, string guardDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0)), + null, // destinationStateSelectorString + new TransitionGuard(guard, guardDescription), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// The receiver. + /// Type of the first trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector) + { + return PermitDynamicIfAsync(trigger, destinationStateSelector, null, new Tuple, string>[0]); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Optional list of possible target states. + /// Functions and their descriptions that must return true in order for the + /// trigger to be accepted. + /// The receiver. + /// Type of the first trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Reflection.DynamicStateInfos possibleDestinationStates = null, params Tuple, string>[] guards) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0)), + null, // destinationStateSelectorString + new TransitionGuard(guards), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Function that must return true in order for the + /// trigger to be accepted. + /// Guard description + /// Optional list of possible target states. + /// The receiver. + /// Type of the first trigger argument. + /// Type of the second trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Func guard, string guardDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0), + ParameterConversion.Unpack(args, 1)), + null, // destinationStateSelectorString + new TransitionGuard(guard, guardDescription), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Optional list of possible target states. + /// Functions and their descriptions that must return true in order for the + /// trigger to be accepted. + /// The receiver. + /// Type of the first trigger argument. + /// Type of the second trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Reflection.DynamicStateInfos possibleDestinationStates = null, params Tuple, string>[] guards) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0), + ParameterConversion.Unpack(args, 1)), + null, // destinationStateSelectorString + new TransitionGuard(guards), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// The receiver. + /// Function that must return true in order for the + /// trigger to be accepted. + /// Guard description + /// Optional list of possible target states. + /// Type of the first trigger argument. + /// Type of the second trigger argument. + /// Type of the third trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Func guard, string guardDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0), + ParameterConversion.Unpack(args, 1), + ParameterConversion.Unpack(args, 2)), + null, // destinationStateSelectorString + new TransitionGuard(guard, guardDescription), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Optional list of possible target states. + /// The receiver. + /// Functions ant their descriptions that must return true in order for the + /// trigger to be accepted. + /// Type of the first trigger argument. + /// Type of the second trigger argument. + /// Type of the third trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Reflection.DynamicStateInfos possibleDestinationStates = null, params Tuple, string>[] guards) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0), + ParameterConversion.Unpack(args, 1), + ParameterConversion.Unpack(args, 2)), + null, // destinationStateSelectorString + new TransitionGuard(guards), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Parameterized Function that must return true in order for the + /// trigger to be accepted. + /// Guard description + /// Optional list of possible target states. + /// The receiver. + /// Type of the first trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Func guard, string guardDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0)), + null, // destinationStateSelectorString + new TransitionGuard(TransitionGuard.ToPackedGuard(guard), guardDescription), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Optional list of possible target states. + /// Functions and their descriptions that must return true in order for the + /// trigger to be accepted. + /// The receiver. + /// Type of the first trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Reflection.DynamicStateInfos possibleDestinationStates = null, params Tuple, string>[] guards) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0)), + null, // destinationStateSelectorString + new TransitionGuard(TransitionGuard.ToPackedGuards(guards)), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Function that must return true in order for the + /// trigger to be accepted. + /// Guard description + /// Optional list of possible target states. + /// The receiver. + /// Type of the first trigger argument. + /// Type of the second trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Func guard, string guardDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0), + ParameterConversion.Unpack(args, 1)), + null, // destinationStateSelectorString + new TransitionGuard(TransitionGuard.ToPackedGuard(guard), guardDescription), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Functions that must return true in order for the + /// trigger to be accepted. + /// Optional list of possible target states. + /// The receiver. + /// Type of the first trigger argument. + /// Type of the second trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Tuple, string>[] guards, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0), + ParameterConversion.Unpack(args, 1)), + null, // destinationStateSelectorString + new TransitionGuard(TransitionGuard.ToPackedGuards(guards)), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Function that must return true in order for the + /// trigger to be accepted. + /// Guard description + /// Optional list of possible target states. + /// The receiver. + /// Type of the first trigger argument. + /// Type of the second trigger argument. + /// Type of the third trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Func guard, string guardDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0), + ParameterConversion.Unpack(args, 1), + ParameterConversion.Unpack(args, 2)), + null, // destinationStateSelectorString + new TransitionGuard(TransitionGuard.ToPackedGuard(guard), guardDescription), + possibleDestinationStates); + } + + /// + /// Accept the specified trigger and transition to the destination state, calculated + /// dynamically by the supplied async function. + /// + /// The accepted trigger. + /// + /// Function to calculate the destination state; if the source and destination states are the same, it will be reentered and + /// any exit or entry logic will be invoked. + /// + /// Functions that must return true in order for the + /// trigger to be accepted. + /// Optional list of possible target states. + /// The receiver. + /// Type of the first trigger argument. + /// Type of the second trigger argument. + /// Type of the third trigger argument. + public StateConfiguration PermitDynamicIfAsync(TriggerWithParameters trigger, Func> destinationStateSelector, Tuple, string>[] guards, Reflection.DynamicStateInfos possibleDestinationStates = null) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + + return InternalPermitDynamicIfAsync( + trigger.Trigger, + args => destinationStateSelector( + ParameterConversion.Unpack(args, 0), + ParameterConversion.Unpack(args, 1), + ParameterConversion.Unpack(args, 2)), + null, // destinationStateSelectorString + new TransitionGuard(TransitionGuard.ToPackedGuards(guards)), + possibleDestinationStates); + } + StateConfiguration InternalPermitDynamicIfAsync(TTrigger trigger, Func> destinationStateSelector, + string destinationStateSelectorDescription, TransitionGuard transitionGuard, Reflection.DynamicStateInfos possibleDestinationStates) + { + if (destinationStateSelector == null) throw new ArgumentNullException(nameof(destinationStateSelector)); + if (transitionGuard == null) throw new ArgumentNullException(nameof(transitionGuard)); + + _representation.AddTriggerBehaviour(new DynamicTriggerBehaviourAsync(trigger, + destinationStateSelector, + transitionGuard, + Reflection.DynamicTransitionInfo.Create(trigger, + transitionGuard.Conditions.Select(x => x.MethodDescription), + Reflection.InvocationInfo.Create(destinationStateSelector, destinationStateSelectorDescription), + possibleDestinationStates) + )); + return this; + } } } } diff --git a/src/Stateless/StateMachine.Async.cs b/src/Stateless/StateMachine.Async.cs index f69d0065..01f3b8f4 100644 --- a/src/Stateless/StateMachine.Async.cs +++ b/src/Stateless/StateMachine.Async.cs @@ -217,6 +217,15 @@ async Task InternalFireOneAsync(TTrigger trigger, params object[] args) // Handle transition, and set new state var transition = new Transition(source, handler.Destination, trigger, args); await HandleReentryTriggerAsync(args, representativeState, transition); + break; + } + case DynamicTriggerBehaviourAsync asyncHandler: + { + var destination = await asyncHandler.GetDestinationState(source, args); + // Handle transition, and set new state; reentry is permitted from dynamic trigger behaviours. + var transition = new Transition(source, destination, trigger, args); + await HandleTransitioningTriggerAsync(args, representativeState, transition); + break; } case DynamicTriggerBehaviour handler: diff --git a/src/Stateless/StateMachine.cs b/src/Stateless/StateMachine.cs index a28c5f48..31e9de65 100644 --- a/src/Stateless/StateMachine.cs +++ b/src/Stateless/StateMachine.cs @@ -422,6 +422,18 @@ private void InternalFireOne(TTrigger trigger, params object[] args) HandleReentryTrigger(args, representativeState, transition); break; } + case DynamicTriggerBehaviourAsync asyncHandler: + { + asyncHandler.GetDestinationState(source, args) + .ContinueWith(t => + { + var destination = t.Result; + // Handle transition, and set new state; reentry is permitted from dynamic trigger behaviours. + var transition = new Transition(source, destination, trigger, args); + return HandleTransitioningTriggerAsync(args, representativeState, transition); + }); + break; + } case DynamicTriggerBehaviour handler: { handler.GetDestinationState(source, args, out var destination); diff --git a/src/Stateless/Stateless.csproj b/src/Stateless/Stateless.csproj index a3d0771c..a0fba466 100644 --- a/src/Stateless/Stateless.csproj +++ b/src/Stateless/Stateless.csproj @@ -4,11 +4,11 @@ Stateless Stateless Stateless - netstandard2.0;net462;net8.0; + netstandard2.0;net462;net8.0;net9.0 Create state machines and lightweight state machine-based workflows directly in .NET code Copyright © Stateless Contributors 2009-$([System.DateTime]::Now.ToString(yyyy)) en-US - 5.16.0 + 5.17.0 Stateless Contributors true true @@ -17,7 +17,6 @@ true Stateless.png https://github.com/dotnet-state-machine/stateless - http://www.apache.org/licenses/LICENSE-2.0 false Apache-2.0 git diff --git a/src/Stateless/docs/README.md b/src/Stateless/docs/README.md index 52d6a74a..496d4789 100644 --- a/src/Stateless/docs/README.md +++ b/src/Stateless/docs/README.md @@ -41,7 +41,7 @@ Some useful extensions are also provided: * Ability to store state externally (for example, in a property tracked by an ORM) * Parameterised triggers * Reentrant states - * Export to DOT graph + * Export to DOT and Mermaid graph ## Documentation diff --git a/test/Stateless.Tests/DotGraphFixture.cs b/test/Stateless.Tests/DotGraphFixture.cs index b16234b4..ce6a78b0 100644 --- a/test/Stateless.Tests/DotGraphFixture.cs +++ b/test/Stateless.Tests/DotGraphFixture.cs @@ -5,6 +5,7 @@ using Xunit; using Stateless.Reflection; using Stateless.Graph; +using System.Threading.Tasks; namespace Stateless.Tests { @@ -136,19 +137,32 @@ public void SimpleTransition() } [Fact] - public void SimpleTransitionUML() + public void SimpleTransitionWithEscaping() { - var expected = Prefix(Style.UML) + Box(Style.UML, "A") + Box(Style.UML, "B") + Line("A", "B", "X") + suffix; + var state1 = "\\state \"1\""; + var state2 = "\\state \"2\""; + var trigger1 = "\\trigger \"1\""; - var sm = new StateMachine(State.A); + string suffix = Environment.NewLine + + $" init [label=\"\", shape=point];" + Environment.NewLine + + $" init -> \"{EscapeLabel(state1)}\"[style = \"solid\"]" + Environment.NewLine + + "}"; - sm.Configure(State.A) - .Permit(Trigger.X, State.B); + var expected = + Prefix(Style.UML) + + Box(Style.UML, EscapeLabel(state1)) + + Box(Style.UML, EscapeLabel(state2)) + + Line(EscapeLabel(state1), EscapeLabel(state2), EscapeLabel(trigger1)) + suffix; + + var sm = new StateMachine(state1); + + sm.Configure(state1) + .Permit(trigger1, state2); string dotGraph = UmlDotGraph.Format(sm.GetInfo()); #if WRITE_DOTS_TO_FOLDER - System.IO.File.WriteAllText(DestinationFolder + "SimpleTransitionUML.dot", dotGraph); + System.IO.File.WriteAllText(DestinationFolder + "SimpleTransitionWithEscaping.dot", dotGraph); #endif Assert.Equal(expected, dotGraph); @@ -196,7 +210,7 @@ public void WhenDiscriminatedByAnonymousGuardWithDescription() var expected = Prefix(Style.UML) + Box(Style.UML, "A") + Box(Style.UML, "B") + Line("A", "B", "X [description]") - + suffix; + + suffix; var sm = new StateMachine(State.A); @@ -258,6 +272,27 @@ public void DestinationStateIsDynamic() string dotGraph = UmlDotGraph.Format(sm.GetInfo()); +#if WRITE_DOTS_TO_FOLDER + System.IO.File.WriteAllText(DestinationFolder + "DestinationStateIsDynamic.dot", dotGraph); +#endif + + Assert.Equal(expected, dotGraph); + } + + [Fact] + public void DestinationStateIsDynamicAsync() + { + var expected = Prefix(Style.UML) + + Box(Style.UML, "A") + + Decision(Style.UML, "Decision1", "Function") + + Line("A", "Decision1", "X") + suffix; + + var sm = new StateMachine(State.A); + sm.Configure(State.A) + .PermitDynamicAsync(Trigger.X, () => Task.FromResult(State.B)); + + string dotGraph = UmlDotGraph.Format(sm.GetInfo()); + #if WRITE_DOTS_TO_FOLDER System.IO.File.WriteAllText(DestinationFolder + "DestinationStateIsDynamic.dot", dotGraph); #endif @@ -280,6 +315,27 @@ public void DestinationStateIsCalculatedBasedOnTriggerParameters() string dotGraph = UmlDotGraph.Format(sm.GetInfo()); +#if WRITE_DOTS_TO_FOLDER + System.IO.File.WriteAllText(DestinationFolder + "DestinationStateIsCalculatedBasedOnTriggerParameters.dot", dotGraph); +#endif + Assert.Equal(expected, dotGraph); + } + + [Fact] + public void DestinationStateIsCalculatedBasedOnTriggerParametersAsync() + { + var expected = Prefix(Style.UML) + + Box(Style.UML, "A") + + Decision(Style.UML, "Decision1", "Function") + + Line("A", "Decision1", "X") + suffix; + + 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)); + + string dotGraph = UmlDotGraph.Format(sm.GetInfo()); + #if WRITE_DOTS_TO_FOLDER System.IO.File.WriteAllText(DestinationFolder + "DestinationStateIsCalculatedBasedOnTriggerParameters.dot", dotGraph); #endif @@ -398,46 +454,50 @@ public void OnEntryWithTriggerParameter() Assert.Equal(expected, dotGraph); } - + [Fact] public void SpacedUmlWithSubstate() { - string StateA = "State A"; - string StateB = "State B"; - string StateC = "State C"; - string StateD = "State D"; - string TriggerX = "Trigger X"; - string TriggerY = "Trigger Y"; - + string StateA = "State \"A\""; + string StateB = "State \"B\""; + string StateC = "State \"C\""; + string StateD = "State \"D\""; + string TriggerX = "Trigger \"X\""; + string TriggerY = "Trigger \"Y\""; + string EnterA = "Enter \"A\""; + string EnterD = "Enter \"D\""; + string ExitA = "Exit \"A\""; + var expected = Prefix(Style.UML) - + 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" }) - + Line(StateA, StateB, TriggerX) + Line(StateA, StateC, TriggerY) - + Environment.NewLine + + Subgraph(Style.UML, EscapeLabel(StateD), $"{EscapeLabel(StateD)}\\n----------\\nentry / {EscapeLabel(EnterD)}", + Box(Style.UML, EscapeLabel(StateB)) + + Box(Style.UML, EscapeLabel(StateC))) + + Box(Style.UML, EscapeLabel(StateA), new List { EscapeLabel(EnterA) }, new List { EscapeLabel(ExitA) }) + + Line(EscapeLabel(StateA), EscapeLabel(StateB), EscapeLabel(TriggerX)) + + Line(EscapeLabel(StateA), EscapeLabel(StateC), EscapeLabel(TriggerY)) + + Environment.NewLine + $" init [label=\"\", shape=point];" + Environment.NewLine - + $" init -> \"{StateA}\"[style = \"solid\"]" + Environment.NewLine + + $" init -> \"{EscapeLabel(StateA)}\"[style = \"solid\"]" + Environment.NewLine + "}"; - var sm = new StateMachine("State A"); + var sm = new StateMachine(StateA); sm.Configure(StateA) .Permit(TriggerX, StateB) .Permit(TriggerY, StateC) - .OnEntry(TestEntryAction, "Enter A") - .OnExit(TestEntryAction, "Exit A"); + .OnEntry(TestEntryAction, EnterA) + .OnExit(TestEntryAction, ExitA); sm.Configure(StateB) .SubstateOf(StateD); sm.Configure(StateC) .SubstateOf(StateD); sm.Configure(StateD) - .OnEntry(TestEntryAction, "Enter D"); + .OnEntry(TestEntryAction, EnterD); 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); @@ -493,7 +553,36 @@ public void UmlWithDynamic() var sm = new StateMachine(State.A); sm.Configure(State.A) - .PermitDynamic(Trigger.X, DestinationSelector, null, new DynamicStateInfos { { State.B, "ChoseB"}, { State.C, "ChoseC" } }); + .PermitDynamic(Trigger.X, DestinationSelector, null, new DynamicStateInfos { { State.B, "ChoseB" }, { State.C, "ChoseC" } }); + + sm.Configure(State.B); + sm.Configure(State.C); + + string dotGraph = UmlDotGraph.Format(sm.GetInfo()); +#if WRITE_DOTS_TO_FOLDER + System.IO.File.WriteAllText(DestinationFolder + "UmlWithDynamic.dot", dotGraph); +#endif + + Assert.Equal(expected, dotGraph); + } + + [Fact] + public void UmlWithDynamicAsync() + { + var expected = Prefix(Style.UML) + + Box(Style.UML, "A") + + Box(Style.UML, "B") + + Box(Style.UML, "C") + + Decision(Style.UML, "Decision1", "Function") + + Line("A", "Decision1", "X") + + Line("Decision1", "B", "X [ChoseB]") + + Line("Decision1", "C", "X [ChoseC]") + + suffix; + + var sm = new StateMachine(State.A); + + sm.Configure(State.A) + .PermitDynamicAsync(Trigger.X, () => Task.FromResult(DestinationSelector()), null, new DynamicStateInfos { { State.B, "ChoseB" }, { State.C, "ChoseC" } }); sm.Configure(State.B); sm.Configure(State.C); @@ -513,7 +602,7 @@ public void TransitionWithIgnoreAndEntry() + Box(Style.UML, "A", new List { "DoEntry" }) + Box(Style.UML, "B", new List { "DoThisEntry" }) + Line("A", "B", "X") - + Line("A", "A", "Y") + + Line("A", "A", "Y") + Line("B", "B", "Z / DoThisEntry") + suffix; @@ -537,6 +626,30 @@ public void TransitionWithIgnoreAndEntry() Assert.Equal(expected, dotGraph); } + [Fact] + public void Internal_Transition_Does_Not_Show_Entry_Exit_Functions() + { + var expected = Prefix(Style.UML) + + Box(Style.UML, "A", new List { "DoEntry" }, new List { "DoExit" }) + + Line("A", "A", "X [Function]") + + suffix; + + var sm = new StateMachine(State.A); + + sm.Configure(State.A) + .OnEntry(x => { }, "DoEntry") + .OnExit(x => { }, "DoExit") + .InternalTransition(Trigger.X, x => { }); + + string dotGraph = UmlDotGraph.Format(sm.GetInfo()); + +#if WRITE_DOTS_TO_FOLDER + System.IO.File.WriteAllText(DestinationFolder + "Internal_Transition_Does_Not_Show_Entry_Exit_Functions.dot", dotGraph); +#endif + + Assert.Equal(expected, dotGraph); + } + [Fact] public void Initial_State_Not_Changed_After_Trigger_Fired() { @@ -558,8 +671,68 @@ public void Initial_State_Not_Changed_After_Trigger_Fired() Assert.Equal(expected, dotGraph); } + [Fact] + public void Reentrant_Transition_Shows_Entry_Action_When_Action_Is_Configured_With_OnEntryFrom() + { + var expected = Prefix(Style.UML) + + Box(Style.UML, "A") + + Box(Style.UML, "B") + + Line("A", "B", "X / OnEntry") + + Line("B", "B", "X / OnEntry") + + suffix; + + var sm = new StateMachine(State.A); + + sm.Configure(State.A) + .Permit(Trigger.X, State.B); + + var list = new List(); + sm.Configure(State.B) + .OnEntryFrom(Trigger.X, OnEntry, entryActionDescription: "OnEntry") + .PermitReentry(Trigger.X); + + string dotGraph = UmlDotGraph.Format(sm.GetInfo()); + +#if WRITE_DOTS_TO_FOLDER + System.IO.File.WriteAllText(DestinationFolder + "Reentrant_Transition_Shows_Entry_Action_When_Action_Is_Configured_With_OnEntryFrom.dot", dotGraph); +#endif + + Assert.Equal(expected, dotGraph); + } + + [Fact] + public void Reentrant_Transition_Shows_Entry_Action_When_Action_Is_Configured_With_OnEntryFrom_And_Trigger_Has_Parameter() + { + var expected = Prefix(Style.UML) + + Box(Style.UML, "A") + + Box(Style.UML, "B") + + Line("A", "B", "X / LogTrigger") + + Line("B", "B", "X / LogTrigger") + + suffix; + + var sm = new StateMachine(State.A); + var triggerX = sm.SetTriggerParameters(Trigger.X); + + sm.Configure(State.A) + .Permit(Trigger.X, State.B); + + var list = new List(); + sm.Configure(State.B) + .OnEntryFrom(triggerX, list.Add, entryActionDescription: "LogTrigger") + .PermitReentry(Trigger.X); + + string dotGraph = UmlDotGraph.Format(sm.GetInfo()); + +#if WRITE_DOTS_TO_FOLDER + System.IO.File.WriteAllText(DestinationFolder + "Reentrant_Transition_Shows_Entry_Action_When_Action_Is_Configured_With_OnEntryFrom_And_Trigger_Has_Parameter.dot", dotGraph); +#endif + + Assert.Equal(expected, dotGraph); + } + private void TestEntryAction() { } private void TestEntryActionString(string val) { } private State DestinationSelector() { return State.A; } + private static string EscapeLabel(string label) { return label.Replace("\\", "\\\\").Replace("\"", "\\\""); } } } diff --git a/test/Stateless.Tests/DynamicAsyncTriggerBehaviourAsyncFixture.cs b/test/Stateless.Tests/DynamicAsyncTriggerBehaviourAsyncFixture.cs new file mode 100644 index 00000000..e7551d1f --- /dev/null +++ b/test/Stateless.Tests/DynamicAsyncTriggerBehaviourAsyncFixture.cs @@ -0,0 +1,168 @@ +using System; +using System.Threading.Tasks; +using Xunit; + +namespace Stateless.Tests +{ + public class DynamicAsyncTriggerBehaviourAsyncFixture + { + [Fact] + public async Task PermitDynamic_Selects_Expected_State_Async() + { + var sm = new StateMachine(State.A); + sm.Configure(State.A) + .PermitDynamicAsync(Trigger.X, async () => { await Task.Delay(100); return State.B; }); + + await sm.FireAsync(Trigger.X); + + Assert.Equal(State.B, sm.State); + } + + [Fact] + public async Task PermitDynamic_With_TriggerParameter_Selects_Expected_State_Async() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitDynamicAsync(trigger, async (i) => { await Task.Delay(100); return i == 1 ? State.B : State.C; }); + + await sm.FireAsync(trigger, 1); + + Assert.Equal(State.B, sm.State); + } + + [Fact] + public async Task PermitDynamic_Permits_Reentry_Async() + { + var sm = new StateMachine(State.A); + var onExitInvoked = false; + var onEntryInvoked = false; + var onEntryFromInvoked = false; + sm.Configure(State.A) + .PermitDynamicAsync(Trigger.X, async () => { await Task.Delay(100); return State.A; }) + .OnEntry(() => onEntryInvoked = true) + .OnEntryFrom(Trigger.X, () => onEntryFromInvoked = true) + .OnExit(() => onExitInvoked = true); + + await sm.FireAsync(Trigger.X); + + Assert.True(onExitInvoked, "Expected OnExit to be invoked"); + Assert.True(onEntryInvoked, "Expected OnEntry to be invoked"); + Assert.True(onEntryFromInvoked, "Expected OnEntryFrom to be invoked"); + Assert.Equal(State.A, sm.State); + } + + [Fact] + public async Task PermitDynamic_Selects_Expected_State_Based_On_DestinationStateSelector_Function_Async() + { + var sm = new StateMachine(State.A); + var value = 'C'; + sm.Configure(State.A) + .PermitDynamicAsync(Trigger.X, async () => { await Task.Delay(100); return value == 'B' ? State.B : State.C; }); + + await sm.FireAsync(Trigger.X); + + Assert.Equal(State.C, sm.State); + } + + [Fact] + public async Task PermitDynamicIf_With_TriggerParameter_Permits_Transition_When_GuardCondition_Met_Async() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitDynamicIfAsync(trigger, async (i) =>{ await Task.Delay(100); return i == 1 ? State.C : State.B; }, (i) => i == 1); + + await sm.FireAsync(trigger, 1); + + Assert.Equal(State.C, sm.State); + } + + [Fact] + public async Task PermitDynamicIf_With_2_TriggerParameters_Permits_Transition_When_GuardCondition_Met_Async() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitDynamicIfAsync( + trigger, + async (i, j) => { await Task.Yield(); return i == 1 && j == 2 ? State.C : State.B; }, + (i, j) => i == 1 && j == 2); + + await sm.FireAsync(trigger, 1, 2); + + Assert.Equal(State.C, sm.State); + } + + [Fact] + public async Task PermitDynamicIf_With_3_TriggerParameters_Permits_Transition_When_GuardCondition_Met_Async() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitDynamicIfAsync( + trigger, + async (i, j, k) => { await Task.Delay(100); return i == 1 && j == 2 && k == 3 ? State.C : State.B; }, + (i, j, k) => i == 1 && j == 2 && k == 3); + + await sm.FireAsync(trigger, 1, 2, 3); + + Assert.Equal(State.C, sm.State); + } + + [Fact] + public async Task PermitDynamicIf_With_TriggerParameter_Throws_When_GuardCondition_Not_Met_Async() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitDynamicIfAsync(trigger, async (i) => { await Task.Delay(100); return i > 0 ? State.C : State.B; }, guard: (i) => i == 2); + + await Assert.ThrowsAsync(async () => await sm.FireAsync(trigger, 1)); + } + + [Fact] + public async Task PermitDynamicIf_With_2_TriggerParameters_Throws_When_GuardCondition_Not_Met_Async() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitDynamicIfAsync( + trigger, + async (i, j) => { await Task.Delay(100); return i > 0 ? State.C : State.B; }, + (i, j) => i == 2 && j == 3); + + await Assert.ThrowsAsync(async () => await sm.FireAsync(trigger, 1, 2)); + } + + [Fact] + public async Task PermitDynamicIf_With_3_TriggerParameters_Throws_When_GuardCondition_Not_Met_Async() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitDynamicIfAsync(trigger, + async (i, j, k) => { await Task.Delay(100); return i > 0 ? State.C : State.B; }, + (i, j, k) => i == 2 && j == 3 && k == 4); + + await Assert.ThrowsAsync(async () => await sm.FireAsync(trigger, 1, 2, 3)); + } + + [Fact] + public async Task PermitDynamicIf_Permits_Reentry_When_GuardCondition_Met_Async() + { + var sm = new StateMachine(State.A); + var onExitInvoked = false; + var onEntryInvoked = false; + var onEntryFromInvoked = false; + sm.Configure(State.A) + .PermitDynamicIfAsync(Trigger.X, async () =>{ await Task.Delay(100); return State.A; }, () => true) + .OnEntry(() => onEntryInvoked = true) + .OnEntryFrom(Trigger.X, () => onEntryFromInvoked = true) + .OnExit(() => onExitInvoked = true); + + await sm.FireAsync(Trigger.X); + + Assert.True(onExitInvoked, "Expected OnExit to be invoked"); + Assert.True(onEntryInvoked, "Expected OnEntry to be invoked"); + Assert.True(onEntryFromInvoked, "Expected OnEntryFrom to be invoked"); + Assert.Equal(State.A, sm.State); + } + } +} diff --git a/test/Stateless.Tests/DynamicAsyncTriggerBehaviourFixture.cs b/test/Stateless.Tests/DynamicAsyncTriggerBehaviourFixture.cs new file mode 100644 index 00000000..be111418 --- /dev/null +++ b/test/Stateless.Tests/DynamicAsyncTriggerBehaviourFixture.cs @@ -0,0 +1,168 @@ +using System; +using System.Threading.Tasks; +using Xunit; + +namespace Stateless.Tests +{ + public class DynamicAsyncTriggerBehaviourFixture + { + [Fact] + public async void PermitDynamic_Selects_Expected_State() + { + var sm = new StateMachine(State.A); + sm.Configure(State.A) + .PermitDynamicAsync(Trigger.X, () => Task.FromResult(State.B)); + + await sm.FireAsync(Trigger.X); + + Assert.Equal(State.B, sm.State); + } + + [Fact] + public async void PermitDynamic_With_TriggerParameter_Selects_Expected_State() + { + 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)); + + await sm.FireAsync(trigger, 1); + + Assert.Equal(State.B, sm.State); + } + + [Fact] + public async void PermitDynamic_Permits_Reentry() + { + var sm = new StateMachine(State.A); + var onExitInvoked = false; + var onEntryInvoked = false; + var onEntryFromInvoked = false; + sm.Configure(State.A) + .PermitDynamicAsync(Trigger.X, () => Task.FromResult(State.A)) + .OnEntry(() => onEntryInvoked = true) + .OnEntryFrom(Trigger.X, () => onEntryFromInvoked = true) + .OnExit(() => onExitInvoked = true); + + await sm.FireAsync(Trigger.X); + + Assert.True(onExitInvoked, "Expected OnExit to be invoked"); + Assert.True(onEntryInvoked, "Expected OnEntry to be invoked"); + Assert.True(onEntryFromInvoked, "Expected OnEntryFrom to be invoked"); + Assert.Equal(State.A, sm.State); + } + + [Fact] + public async void PermitDynamic_Selects_Expected_State_Based_On_DestinationStateSelector_Function() + { + var sm = new StateMachine(State.A); + var value = 'C'; + sm.Configure(State.A) + .PermitDynamicAsync(Trigger.X, () => Task.FromResult(value == 'B' ? State.B : State.C)); + + await sm.FireAsync(Trigger.X); + + Assert.Equal(State.C, sm.State); + } + + [Fact] + public async void PermitDynamicIf_With_TriggerParameter_Permits_Transition_When_GuardCondition_Met() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitDynamicIfAsync(trigger, (i) => Task.FromResult(i == 1 ? State.C : State.B), (i) => i == 1); + + await sm.FireAsync(trigger, 1); + + Assert.Equal(State.C, sm.State); + } + + [Fact] + public async void PermitDynamicIf_With_2_TriggerParameters_Permits_Transition_When_GuardCondition_Met() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitDynamicIfAsync( + trigger, + (i, j) => Task.FromResult(i == 1 && j == 2 ? State.C : State.B), + (i, j) => i == 1 && j == 2); + + await sm.FireAsync(trigger, 1, 2); + + Assert.Equal(State.C, sm.State); + } + + [Fact] + public async void PermitDynamicIf_With_3_TriggerParameters_Permits_Transition_When_GuardCondition_Met() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitDynamicIfAsync( + trigger, + (i, j, k) => Task.FromResult(i == 1 && j == 2 && k == 3 ? State.C : State.B), + (i, j, k) => i == 1 && j == 2 && k == 3); + + await sm.FireAsync(trigger, 1, 2, 3); + + Assert.Equal(State.C, sm.State); + } + + [Fact] + public void PermitDynamicIf_With_TriggerParameter_Throws_When_GuardCondition_Not_Met() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitDynamicIfAsync(trigger, (i) => Task.FromResult(i > 0 ? State.C : State.B), (i) => i == 2 ? true : false); + + Assert.Throws(() => sm.Fire(trigger, 1)); + } + + [Fact] + public void PermitDynamicIf_With_2_TriggerParameters_Throws_When_GuardCondition_Not_Met() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitDynamicIfAsync( + trigger, + (i, j) => Task.FromResult(i > 0 ? State.C : State.B), + (i, j) => i == 2 && j == 3); + + Assert.Throws(() => sm.Fire(trigger, 1, 2)); + } + + [Fact] + public void PermitDynamicIf_With_3_TriggerParameters_Throws_When_GuardCondition_Not_Met() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitDynamicIfAsync(trigger, + (i, j, k) => Task.FromResult(i > 0 ? State.C : State.B), + (i, j, k) => i == 2 && j == 3 && k == 4); + + Assert.Throws(() => sm.Fire(trigger, 1, 2, 3)); + } + + [Fact] + public async void PermitDynamicIf_Permits_Reentry_When_GuardCondition_Met() + { + var sm = new StateMachine(State.A); + var onExitInvoked = false; + var onEntryInvoked = false; + var onEntryFromInvoked = false; + sm.Configure(State.A) + .PermitDynamicIfAsync(Trigger.X, () => Task.FromResult(State.A), () => true) + .OnEntry(() => onEntryInvoked = true) + .OnEntryFrom(Trigger.X, () => onEntryFromInvoked = true) + .OnExit(() => onExitInvoked = true); + + await sm.FireAsync(Trigger.X); + + Assert.True(onExitInvoked, "Expected OnExit to be invoked"); + Assert.True(onEntryInvoked, "Expected OnEntry to be invoked"); + Assert.True(onEntryFromInvoked, "Expected OnEntryFrom to be invoked"); + Assert.Equal(State.A, sm.State); + } + } +} diff --git a/test/Stateless.Tests/GetInfoFixture.cs b/test/Stateless.Tests/GetInfoFixture.cs index d8a77825..570ee914 100644 --- a/test/Stateless.Tests/GetInfoFixture.cs +++ b/test/Stateless.Tests/GetInfoFixture.cs @@ -1,41 +1,42 @@ using System.Threading.Tasks; using Xunit; -namespace Stateless.Tests; - -public class GetInfoFixture +namespace Stateless.Tests { - [Fact] - public void GetInfo_should_return_Entry_action_with_trigger_name() + public class GetInfoFixture { - // ARRANGE - var sm = new StateMachine(State.A); - sm.Configure(State.B) - .OnEntryFrom(Trigger.X, () => { }); + [Fact] + public void GetInfo_should_return_Entry_action_with_trigger_name() + { + // ARRANGE + var sm = new StateMachine(State.A); + sm.Configure(State.B) + .OnEntryFrom(Trigger.X, () => { }); - // ACT - var stateMachineInfo = sm.GetInfo(); + // ACT + var stateMachineInfo = sm.GetInfo(); - // ASSERT - var stateInfo = Assert.Single(stateMachineInfo.States); - var entryActionInfo = Assert.Single(stateInfo.EntryActions); - Assert.Equal(Trigger.X.ToString(), entryActionInfo.FromTrigger); - } + // ASSERT + var stateInfo = Assert.Single(stateMachineInfo.States); + var entryActionInfo = Assert.Single(stateInfo.EntryActions); + Assert.Equal(Trigger.X.ToString(), entryActionInfo.FromTrigger); + } - [Fact] - public void GetInfo_should_return_async_Entry_action_with_trigger_name() - { - // ARRANGE - var sm = new StateMachine(State.A); - sm.Configure(State.B) - .OnEntryFromAsync(Trigger.X, () => Task.CompletedTask); + [Fact] + public void GetInfo_should_return_async_Entry_action_with_trigger_name() + { + // ARRANGE + var sm = new StateMachine(State.A); + sm.Configure(State.B) + .OnEntryFromAsync(Trigger.X, () => Task.CompletedTask); - // ACT - var stateMachineInfo = sm.GetInfo(); + // ACT + var stateMachineInfo = sm.GetInfo(); - // ASSERT - var stateInfo = Assert.Single(stateMachineInfo.States); - var entryActionInfo = Assert.Single(stateInfo.EntryActions); - Assert.Equal(Trigger.X.ToString(), entryActionInfo.FromTrigger); + // ASSERT + var stateInfo = Assert.Single(stateMachineInfo.States); + var entryActionInfo = Assert.Single(stateInfo.EntryActions); + Assert.Equal(Trigger.X.ToString(), entryActionInfo.FromTrigger); + } } } \ No newline at end of file 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 + } + } +} diff --git a/test/Stateless.Tests/ReflectionFixture.cs b/test/Stateless.Tests/ReflectionFixture.cs index eff37206..ef688823 100644 --- a/test/Stateless.Tests/ReflectionFixture.cs +++ b/test/Stateless.Tests/ReflectionFixture.cs @@ -382,6 +382,39 @@ public void DestinationStateIsDynamic_Binding() } } + [Fact] + public void DestinationStateIsDynamicAsync_Binding() + { + var sm = new StateMachine(State.A); + sm.Configure(State.A) + .PermitDynamicAsync(Trigger.X, () => Task.FromResult(State.B)); + + StateMachineInfo inf = sm.GetInfo(); + + Assert.True(inf.StateType == typeof(State)); + Assert.Equal(inf.TriggerType, typeof(Trigger)); + Assert.Equal(inf.States.Count(), 1); + var binding = inf.States.Single(s => (State)s.UnderlyingState == State.A); + + Assert.True(binding.UnderlyingState is State); + Assert.Equal(State.A, (State)binding.UnderlyingState); + // + Assert.Equal(0, binding.Substates.Count()); + Assert.Equal(null, binding.Superstate); + Assert.Equal(0, binding.EntryActions.Count()); + Assert.Equal(0, binding.ExitActions.Count()); + // + Assert.Equal(0, binding.FixedTransitions.Count()); // Binding transition count mismatch + Assert.Equal(0, binding.IgnoredTriggers.Count()); + Assert.Equal(1, binding.DynamicTransitions.Count()); // Dynamic transition count mismatch + foreach (DynamicTransitionInfo trans in binding.DynamicTransitions) + { + Assert.True(trans.Trigger.UnderlyingTrigger is Trigger); + Assert.Equal(Trigger.X, (Trigger)trans.Trigger.UnderlyingTrigger); + Assert.Equal(0, trans.GuardConditionsMethodDescriptions.Count()); + } + } + [Fact] public void DestinationStateIsCalculatedBasedOnTriggerParameters_Binding() { @@ -416,6 +449,40 @@ public void DestinationStateIsCalculatedBasedOnTriggerParameters_Binding() } } + [Fact] + public void DestinationStateIsCalculatedBasedOnTriggerParameters_BindingAsync() + { + 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)); + + StateMachineInfo inf = sm.GetInfo(); + + Assert.True(inf.StateType == typeof(State)); + Assert.Equal(inf.TriggerType, typeof(Trigger)); + Assert.Equal(inf.States.Count(), 1); + var binding = inf.States.Single(s => (State)s.UnderlyingState == State.A); + + Assert.True(binding.UnderlyingState is State); + Assert.Equal(State.A, (State)binding.UnderlyingState); + // + Assert.Equal(0, binding.Substates.Count()); + Assert.Equal(null, binding.Superstate); + Assert.Equal(0, binding.EntryActions.Count()); + Assert.Equal(0, binding.ExitActions.Count()); + // + Assert.Equal(0, binding.FixedTransitions.Count()); // Binding transition count mismatch" + Assert.Equal(0, binding.IgnoredTriggers.Count()); + Assert.Equal(1, binding.DynamicTransitions.Count()); // Dynamic transition count mismatch + foreach (DynamicTransitionInfo trans in binding.DynamicTransitions) + { + Assert.True(trans.Trigger.UnderlyingTrigger is Trigger); + Assert.Equal(Trigger.X, (Trigger)trans.Trigger.UnderlyingTrigger); + Assert.Equal(0, trans.GuardConditionsMethodDescriptions.Count()); + } + } + [Fact] public void OnEntryWithAnonymousActionAndDescription_Binding() { @@ -893,6 +960,74 @@ StateConfiguration InternalPermitDynamic(TTrigger trigger, Func(State.A); + + sm.Configure(State.A) + .PermitIf(Trigger.X, State.B, Permit); + sm.Configure(State.B) + .PermitIf(Trigger.X, State.C, Permit, UserDescription + "B-Permit"); + sm.Configure(State.C) + .PermitIf(Trigger.X, State.B, () => Permit()); + sm.Configure(State.D) + .PermitIf(Trigger.X, State.C, () => Permit(), UserDescription + "D-Permit"); + + StateMachineInfo inf = sm.GetInfo(); + + foreach (StateInfo stateInfo in inf.States) + { + Assert.Equal(1, stateInfo.Transitions.Count()); + TransitionInfo transInfo = stateInfo.Transitions.First(); + Assert.Equal(1, transInfo.GuardConditionsMethodDescriptions.Count()); + VerifyMethodNames(transInfo.GuardConditionsMethodDescriptions, "", "Permit", (State)stateInfo.UnderlyingState, InvocationInfo.Timing.Synchronous); + } + + + // -------------------------------------------------------- + + sm = new StateMachine(State.A); + + sm.Configure(State.A) + .PermitDynamicIfAsync(Trigger.X, () => Task.FromResult(NextState()), Permit); + sm.Configure(State.B) + .PermitDynamicIfAsync(Trigger.X, () => Task.FromResult(NextState()), Permit, UserDescription + "B-Permit"); + sm.Configure(State.C) + .PermitDynamicIfAsync(Trigger.X, () => Task.FromResult(NextState()), () => Permit()); + sm.Configure(State.D) + .PermitDynamicIfAsync(Trigger.X, () => Task.FromResult(NextState()), () => Permit(), UserDescription + "D-Permit"); + + inf = sm.GetInfo(); + + foreach (StateInfo stateInfo in inf.States) + { + Assert.Equal(1, stateInfo.Transitions.Count()); + TransitionInfo transInfo = stateInfo.Transitions.First(); + Assert.Equal(1, transInfo.GuardConditionsMethodDescriptions.Count()); + VerifyMethodNames(transInfo.GuardConditionsMethodDescriptions, "", "Permit", (State)stateInfo.UnderlyingState, InvocationInfo.Timing.Synchronous); + } + + /* + public IgnoredTriggerBehaviour(TTrigger trigger, Func guard, string description = null) + : base(trigger, new TransitionGuard(guard, description)) + public InternalTriggerBehaviour(TTrigger trigger, Func guard) + : base(trigger, new TransitionGuard(guard, "Internal Transition")) + public TransitioningTriggerBehaviour(TTrigger trigger, TState destination, Func guard = null, string guardDescription = null) + : base(trigger, new TransitionGuard(guard, guardDescription)) + + public StateConfiguration PermitReentryIf(TTrigger trigger, Func guard, string guardDescription = null) + + public StateConfiguration PermitDynamicIf(TriggerWithParameters trigger, Func destinationStateSelector, Func guard, string guardDescription = null) + public StateConfiguration PermitDynamicIf(TriggerWithParameters trigger, Func destinationStateSelector, Func guard, string guardDescription = null) + public StateConfiguration PermitDynamicIf(TriggerWithParameters trigger, Func destinationStateSelector, Func guard, string guardDescription = null) + + StateConfiguration InternalPermit(TTrigger trigger, TState destinationState, string guardDescription) + StateConfiguration InternalPermitDynamic(TTrigger trigger, Func destinationStateSelector, string guardDescription) + */ + } + + [Fact] public void InvocationInfo_Description_Property_When_Method_Name_Is_Null_Returns_String_Literal_Null() { diff --git a/test/Stateless.Tests/StateInfoTests.cs b/test/Stateless.Tests/StateInfoTests.cs index edd8f33e..320cd8db 100644 --- a/test/Stateless.Tests/StateInfoTests.cs +++ b/test/Stateless.Tests/StateInfoTests.cs @@ -1,25 +1,26 @@ using Stateless.Reflection; using Xunit; -namespace Stateless.Tests; - -public class StateInfoTests +namespace Stateless.Tests { - /// - /// For StateInfo, Substates, FixedTransitions and DynamicTransitions are only initialised by a call to AddRelationships. - /// However, for StateMachineInfo.InitialState, this never happens. Therefore StateMachineInfo.InitialState.Transitions - /// throws a System.ArgumentNullException. - /// - [Fact] - public void StateInfo_transitions_should_default_to_empty() + public class StateInfoTests { - // ARRANGE - var stateInfo = StateInfo.CreateStateInfo(new StateMachine.StateRepresentation(State.A)); + /// + /// For StateInfo, Substates, FixedTransitions and DynamicTransitions are only initialised by a call to AddRelationships. + /// However, for StateMachineInfo.InitialState, this never happens. Therefore StateMachineInfo.InitialState.Transitions + /// throws a System.ArgumentNullException. + /// + [Fact] + public void StateInfo_transitions_should_default_to_empty() + { + // ARRANGE + var stateInfo = StateInfo.CreateStateInfo(new StateMachine.StateRepresentation(State.A)); - // ACT - var stateInfoTransitions = stateInfo.Transitions; + // ACT + var stateInfoTransitions = stateInfo.Transitions; - // ASSERT - Assert.Null(stateInfoTransitions); - } + // ASSERT + Assert.Null(stateInfoTransitions); + } + } } \ No newline at end of file diff --git a/test/Stateless.Tests/StateMachineFixture.cs b/test/Stateless.Tests/StateMachineFixture.cs index 91cb42ac..a347b72c 100644 --- a/test/Stateless.Tests/StateMachineFixture.cs +++ b/test/Stateless.Tests/StateMachineFixture.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Xunit; namespace Stateless.Tests @@ -912,6 +913,18 @@ public void TransitionWhenPermitDyanmicIfHasMultipleExclusiveGuards() Assert.Equal(sm.State, State.B); } + [Fact] + public async void TransitionWhenPermitDyanmicIfAsyncHasMultipleExclusiveGuards() + { + var sm = new StateMachine(State.A); + var x = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitDynamicIfAsync(x, i => Task.FromResult(i == 3 ? State.B : State.C), i => i == 3 || i == 5) + .PermitDynamicIfAsync(x, i => Task.FromResult(i == 2 ? State.C : State.D), i => i == 2 || i == 4); + await sm.FireAsync(x, 3); + Assert.Equal(sm.State, State.B); + } + [Fact] public void ExceptionWhenPermitDyanmicIfHasMultipleNonExclusiveGuards() { @@ -922,6 +935,16 @@ public void ExceptionWhenPermitDyanmicIfHasMultipleNonExclusiveGuards() Assert.Throws(() => sm.Fire(x, 2)); } + [Fact] + public void ExceptionWhenPermitDyanmicIfAsyncHasMultipleNonExclusiveGuards() + { + var sm = new StateMachine(State.A); + var x = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitDynamicIfAsync(x, i => Task.FromResult(i == 4 ? State.B : State.C), i => i % 2 == 0) + .PermitDynamicIfAsync(x, i => Task.FromResult(i == 2 ? State.C : State.D), i => i == 2); + + Assert.Throws(() => sm.Fire(x, 2)); + } [Fact] public void TransitionWhenPermitIfHasMultipleExclusiveGuardsWithSuperStateTrue() diff --git a/test/Stateless.Tests/Stateless.Tests.csproj b/test/Stateless.Tests/Stateless.Tests.csproj index d94f9231..2eed06a8 100644 --- a/test/Stateless.Tests/Stateless.Tests.csproj +++ b/test/Stateless.Tests/Stateless.Tests.csproj @@ -3,7 +3,7 @@ $(DefineConstants);TASKS true - net8.0 + net462;net8.0;net9.0 false Stateless.Tests ../../asset/Stateless.snk @@ -20,7 +20,7 @@ - + diff --git a/test/Stateless.Tests/Stateless.Tests.sln b/test/Stateless.Tests/Stateless.Tests.sln new file mode 100644 index 00000000..4fc08418 --- /dev/null +++ b/test/Stateless.Tests/Stateless.Tests.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stateless.Tests", "Stateless.Tests.csproj", "{AAEE5EF9-84E2-42BB-838B-6EBA7F4825B5}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {AAEE5EF9-84E2-42BB-838B-6EBA7F4825B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AAEE5EF9-84E2-42BB-838B-6EBA7F4825B5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AAEE5EF9-84E2-42BB-838B-6EBA7F4825B5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AAEE5EF9-84E2-42BB-838B-6EBA7F4825B5}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {EC9983B3-95F4-42D0-9FFC-F3DD2B863F80} + EndGlobalSection +EndGlobal diff --git a/test/Stateless.Tests/SynchronizationContextFixture.cs b/test/Stateless.Tests/SynchronizationContextFixture.cs index c4f8cacd..43633aaf 100644 --- a/test/Stateless.Tests/SynchronizationContextFixture.cs +++ b/test/Stateless.Tests/SynchronizationContextFixture.cs @@ -1,341 +1,362 @@ +using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Xunit; using Xunit.Sdk; -namespace Stateless.Tests; - -public class SynchronizationContextFixture +namespace Stateless.Tests { - // Define a custom SynchronizationContext. All calls made to delegates should be with this context. - private readonly MaxConcurrencySyncContext _customSynchronizationContext = new(3); - private readonly List _capturedSyncContext = new(); - - private StateMachine GetSut(State initialState = State.A) + public class SynchronizationContextFixture { - return new StateMachine(initialState, FiringMode.Queued) + // Define a custom SynchronizationContext. All calls made to delegates should be with this context. + private readonly MaxConcurrencySyncContext _customSynchronizationContext = new MaxConcurrencySyncContext(3); + private readonly List _capturedSyncContext = new List(); + + private StateMachine GetSut(State initialState = State.A) { - RetainSynchronizationContext = true - }; - } + return new StateMachine(initialState, FiringMode.Queued) + { + RetainSynchronizationContext = true + }; + } - private void SetSyncContext() - { - SynchronizationContext.SetSynchronizationContext(_customSynchronizationContext); - } + private void SetSyncContext() + { + SynchronizationContext.SetSynchronizationContext(_customSynchronizationContext); + } - /// - /// Simulate a call that loses the synchronization context - /// - private async Task CaptureThenLoseSyncContext() - { - CaptureSyncContext(); - await LoseSyncContext().ConfigureAwait(false); // ConfigureAwait false here to ensure we continue using the sync context returned by LoseSyncContext - } + /// + /// Simulate a call that loses the synchronization context + /// + private async Task CaptureThenLoseSyncContext() + { + CaptureSyncContext(); + await LoseSyncContext().ConfigureAwait(false); // ConfigureAwait false here to ensure we continue using the sync context returned by LoseSyncContext + } - private void CaptureSyncContext() - { - _capturedSyncContext.Add(SynchronizationContext.Current); - } + private void CaptureSyncContext() + { + _capturedSyncContext.Add(SynchronizationContext.Current); + } - private async Task LoseSyncContext() - { - await Task.Run(() => { }).ConfigureAwait(false); // Switch synchronization context and continue - Assert.NotEqual(_customSynchronizationContext, SynchronizationContext.Current); - } + private async Task LoseSyncContext() + { + await new CompletesOnDifferentThreadAwaitable(); // Switch synchronization context and continue + Assert.NotEqual(_customSynchronizationContext, SynchronizationContext.Current); + } - /// - /// Tests capture the SynchronizationContext at various points through out their execution. - /// This asserts that every capture is the expected SynchronizationContext instance and that is hasn't been lost. - /// - /// Ensure that we have the expected number of captures - private void AssertSyncContextAlwaysRetained(int numberOfExpectedCalls) - { - Assert.Equal(numberOfExpectedCalls, _capturedSyncContext.Count); - Assert.All(_capturedSyncContext, actual => Assert.Equal(_customSynchronizationContext, actual)); - } + /// + /// Tests capture the SynchronizationContext at various points throughout their execution. + /// This asserts that every capture is the expected SynchronizationContext instance and that it hasn't been lost. + /// + /// Ensure that we have the expected number of captures + // ReSharper disable once ParameterOnlyUsedForPreconditionCheck.Local + private void AssertSyncContextAlwaysRetained(int numberOfExpectedCalls) + { + Assert.Equal(numberOfExpectedCalls, _capturedSyncContext.Count); + Assert.All(_capturedSyncContext, actual => Assert.Equal(_customSynchronizationContext, actual)); + } - /// - /// XUnit uses its own SynchronizationContext to execute each test. Therefore, placing SetSyncContext() in the constructor instead of - /// at the start of every test does not work as desired. This test ensures XUnit's behaviour has not changed. - /// - [Fact] - public void Ensure_XUnit_is_using_SyncContext() - { - SetSyncContext(); - CaptureSyncContext(); - AssertSyncContextAlwaysRetained(1); - } + /// + /// XUnit uses its own SynchronizationContext to execute each test. Therefore, placing SetSyncContext() in the constructor instead of + /// at the start of every test does not work as desired. This test ensures XUnit's behaviour has not changed. + /// + [Fact] + public void Ensure_XUnit_is_using_SyncContext() + { + SetSyncContext(); + CaptureSyncContext(); + AssertSyncContextAlwaysRetained(1); + } - /// - /// SynchronizationContext are funny things. The way that they are lost varies depending on their implementation. - /// This test ensures that our mechanism for losing the SynchronizationContext works. - /// - [Fact] - public async Task Ensure_XUnit_can_lose_sync_context() - { - SetSyncContext(); - await LoseSyncContext().ConfigureAwait(false); - Assert.NotEqual(_customSynchronizationContext, SynchronizationContext.Current); - } + /// + /// SynchronizationContext are funny things. The way that they are lost varies depending on their implementation. + /// This test ensures that our mechanism for losing the SynchronizationContext works. + /// + [Fact] + public async Task Ensure_XUnit_can_lose_sync_context() + { + SetSyncContext(); + await LoseSyncContext().ConfigureAwait(false); + Assert.NotEqual(_customSynchronizationContext, SynchronizationContext.Current); + } - [Fact] - public async Task Activation_of_state_with_superstate_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A) - .OnActivateAsync(CaptureThenLoseSyncContext) - .SubstateOf(State.B); + [Fact] + public async Task Activation_of_state_with_superstate_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .OnActivateAsync(CaptureThenLoseSyncContext) + .SubstateOf(State.B); - sm.Configure(State.B) - .OnActivateAsync(CaptureThenLoseSyncContext); + sm.Configure(State.B) + .OnActivateAsync(CaptureThenLoseSyncContext); - // ACT - await sm.ActivateAsync(); + // ACT + await sm.ActivateAsync(); - // ASSERT - AssertSyncContextAlwaysRetained(2); - } + // ASSERT + AssertSyncContextAlwaysRetained(2); + } - [Fact] - public async Task Deactivation_of_state_with_superstate_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A) - .OnDeactivateAsync(CaptureThenLoseSyncContext) - .SubstateOf(State.B); + [Fact] + public async Task Deactivation_of_state_with_superstate_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .OnDeactivateAsync(CaptureThenLoseSyncContext) + .SubstateOf(State.B); - sm.Configure(State.B) - .OnDeactivateAsync(CaptureThenLoseSyncContext); + sm.Configure(State.B) + .OnDeactivateAsync(CaptureThenLoseSyncContext); - // ACT - await sm.DeactivateAsync(); + // ACT + await sm.DeactivateAsync(); - // ASSERT - AssertSyncContextAlwaysRetained(2); - } + // ASSERT + AssertSyncContextAlwaysRetained(2); + } - [Fact] - public async Task Multiple_activations_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A) - .OnActivateAsync(CaptureThenLoseSyncContext) - .OnActivateAsync(CaptureThenLoseSyncContext) - .OnActivateAsync(CaptureThenLoseSyncContext); + [Fact] + public async Task Multiple_activations_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .OnActivateAsync(CaptureThenLoseSyncContext) + .OnActivateAsync(CaptureThenLoseSyncContext) + .OnActivateAsync(CaptureThenLoseSyncContext); - // ACT - await sm.ActivateAsync(); + // ACT + await sm.ActivateAsync(); - // ASSERT - AssertSyncContextAlwaysRetained(3); - } + // ASSERT + AssertSyncContextAlwaysRetained(3); + } - [Fact] - public async Task Multiple_Deactivations_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A) - .OnDeactivateAsync(CaptureThenLoseSyncContext) - .OnDeactivateAsync(CaptureThenLoseSyncContext) - .OnDeactivateAsync(CaptureThenLoseSyncContext); + [Fact] + public async Task Multiple_Deactivations_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .OnDeactivateAsync(CaptureThenLoseSyncContext) + .OnDeactivateAsync(CaptureThenLoseSyncContext) + .OnDeactivateAsync(CaptureThenLoseSyncContext); - // ACT - await sm.DeactivateAsync(); + // ACT + await sm.DeactivateAsync(); - // ASSERT - AssertSyncContextAlwaysRetained(3); - } + // ASSERT + AssertSyncContextAlwaysRetained(3); + } - [Fact] - public async Task Multiple_OnEntry_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A).Permit(Trigger.X, State.B); - sm.Configure(State.B) - .OnEntryAsync(CaptureThenLoseSyncContext) - .OnEntryAsync(CaptureThenLoseSyncContext) - .OnEntryAsync(CaptureThenLoseSyncContext); + [Fact] + public async Task Multiple_OnEntry_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A).Permit(Trigger.X, State.B); + sm.Configure(State.B) + .OnEntryAsync(CaptureThenLoseSyncContext) + .OnEntryAsync(CaptureThenLoseSyncContext) + .OnEntryAsync(CaptureThenLoseSyncContext); - // ACT - await sm.FireAsync(Trigger.X); + // ACT + await sm.FireAsync(Trigger.X); - // ASSERT - AssertSyncContextAlwaysRetained(3); - } + // ASSERT + AssertSyncContextAlwaysRetained(3); + } - [Fact] - public async Task Multiple_OnExit_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A) - .Permit(Trigger.X, State.B) - .OnExitAsync(CaptureThenLoseSyncContext) - .OnExitAsync(CaptureThenLoseSyncContext) - .OnExitAsync(CaptureThenLoseSyncContext); - sm.Configure(State.B); + [Fact] + public async Task Multiple_OnExit_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .Permit(Trigger.X, State.B) + .OnExitAsync(CaptureThenLoseSyncContext) + .OnExitAsync(CaptureThenLoseSyncContext) + .OnExitAsync(CaptureThenLoseSyncContext); + sm.Configure(State.B); - // ACT - await sm.FireAsync(Trigger.X); + // ACT + await sm.FireAsync(Trigger.X); - // ASSERT - AssertSyncContextAlwaysRetained(3); - } + // ASSERT + AssertSyncContextAlwaysRetained(3); + } - [Fact] - public async Task OnExit_state_with_superstate_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(State.B); - sm.Configure(State.A) - .OnExitAsync(CaptureThenLoseSyncContext) - ; + [Fact] + public async Task OnExit_state_with_superstate_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(State.B); + sm.Configure(State.A) + .OnExitAsync(CaptureThenLoseSyncContext) + ; - sm.Configure(State.B) - .SubstateOf(State.A) - .Permit(Trigger.X, State.C) - .OnExitAsync(CaptureThenLoseSyncContext) - ; - sm.Configure(State.C); + sm.Configure(State.B) + .SubstateOf(State.A) + .Permit(Trigger.X, State.C) + .OnExitAsync(CaptureThenLoseSyncContext) + ; + sm.Configure(State.C); - // ACT - await sm.FireAsync(Trigger.X); + // ACT + await sm.FireAsync(Trigger.X); - // ASSERT - AssertSyncContextAlwaysRetained(2); - } + // ASSERT + AssertSyncContextAlwaysRetained(2); + } - [Fact] - public async Task OnExit_state_and_superstate_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(State.C); - sm.Configure(State.A); + [Fact] + public async Task OnExit_state_and_superstate_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(State.C); + sm.Configure(State.A); - sm.Configure(State.B) - .SubstateOf(State.A) - .OnExitAsync(CaptureThenLoseSyncContext); + sm.Configure(State.B) + .SubstateOf(State.A) + .OnExitAsync(CaptureThenLoseSyncContext); - sm.Configure(State.C) - .SubstateOf(State.B) - .Permit(Trigger.X, State.A) - .OnExitAsync(CaptureThenLoseSyncContext); + sm.Configure(State.C) + .SubstateOf(State.B) + .Permit(Trigger.X, State.A) + .OnExitAsync(CaptureThenLoseSyncContext); - // ACT - await sm.FireAsync(Trigger.X); + // ACT + await sm.FireAsync(Trigger.X); - // ASSERT - AssertSyncContextAlwaysRetained(2); - } + // ASSERT + AssertSyncContextAlwaysRetained(2); + } - [Fact] - public async Task Multiple_OnEntry_on_Reentry_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A).PermitReentry(Trigger.X) - .OnEntryAsync(CaptureThenLoseSyncContext) - .OnEntryAsync(CaptureThenLoseSyncContext); + [Fact] + public async Task Multiple_OnEntry_on_Reentry_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A).PermitReentry(Trigger.X) + .OnEntryAsync(CaptureThenLoseSyncContext) + .OnEntryAsync(CaptureThenLoseSyncContext); - // ACT - await sm.FireAsync(Trigger.X); + // ACT + await sm.FireAsync(Trigger.X); - // ASSERT - AssertSyncContextAlwaysRetained(2); - } + // ASSERT + AssertSyncContextAlwaysRetained(2); + } - [Fact] - public async Task Multiple_OnExit_on_Reentry_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A).PermitReentry(Trigger.X) - .OnExitAsync(CaptureThenLoseSyncContext) - .OnExitAsync(CaptureThenLoseSyncContext); + [Fact] + public async Task Multiple_OnExit_on_Reentry_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A).PermitReentry(Trigger.X) + .OnExitAsync(CaptureThenLoseSyncContext) + .OnExitAsync(CaptureThenLoseSyncContext); - // ACT - await sm.FireAsync(Trigger.X); + // ACT + await sm.FireAsync(Trigger.X); - // ASSERT - AssertSyncContextAlwaysRetained(2); - } + // ASSERT + AssertSyncContextAlwaysRetained(2); + } - [Fact] - public async Task Trigger_firing_another_Trigger_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A) - .InternalTransitionAsync(Trigger.X, async () => - { - await CaptureThenLoseSyncContext(); - await sm.FireAsync(Trigger.Y); - }) - .Permit(Trigger.Y, State.B) - ; - sm.Configure(State.B) - .OnEntryAsync(CaptureThenLoseSyncContext); + [Fact] + public async Task Trigger_firing_another_Trigger_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .InternalTransitionAsync(Trigger.X, async () => + { + await CaptureThenLoseSyncContext(); + await sm.FireAsync(Trigger.Y); + }) + .Permit(Trigger.Y, State.B) + ; + sm.Configure(State.B) + .OnEntryAsync(CaptureThenLoseSyncContext); - // ACT - await sm.FireAsync(Trigger.X); + // ACT + await sm.FireAsync(Trigger.X); - // ASSERT - AssertSyncContextAlwaysRetained(2); - } + // ASSERT + AssertSyncContextAlwaysRetained(2); + } - [Fact] - public async Task OnTransition_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A) - .Permit(Trigger.X, State.B); + [Fact] + public async Task OnTransition_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .Permit(Trigger.X, State.B); - sm.Configure(State.B); + sm.Configure(State.B); - sm.OnTransitionedAsync(_ => CaptureThenLoseSyncContext()); - sm.OnTransitionedAsync(_ => CaptureThenLoseSyncContext()); - sm.OnTransitionedAsync(_ => CaptureThenLoseSyncContext()); + sm.OnTransitionedAsync(_ => CaptureThenLoseSyncContext()); + sm.OnTransitionedAsync(_ => CaptureThenLoseSyncContext()); + sm.OnTransitionedAsync(_ => CaptureThenLoseSyncContext()); - // ACT - await sm.FireAsync(Trigger.X); + // ACT + await sm.FireAsync(Trigger.X); - // ASSERT - AssertSyncContextAlwaysRetained(3); - } + // ASSERT + AssertSyncContextAlwaysRetained(3); + } - [Fact] - public async Task InternalTransition_firing_a_sync_action_should_retain_SyncContext() - { - // ARRANGE - SetSyncContext(); - var sm = GetSut(); - sm.Configure(State.A) - .InternalTransition(Trigger.X, CaptureSyncContext); + [Fact] + public async Task InternalTransition_firing_a_sync_action_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .InternalTransition(Trigger.X, CaptureSyncContext); - // ACT - await sm.FireAsync(Trigger.X); + // ACT + await sm.FireAsync(Trigger.X); - // ASSERT - AssertSyncContextAlwaysRetained(1); + // ASSERT + AssertSyncContextAlwaysRetained(1); + } + + private class CompletesOnDifferentThreadAwaitable + { + public CompletesOnDifferentThreadAwaiter GetAwaiter() => new CompletesOnDifferentThreadAwaiter(); + + internal class CompletesOnDifferentThreadAwaiter : INotifyCompletion + { + public void GetResult() { } + + public bool IsCompleted => false; + + public void OnCompleted(Action continuation) + { + ThreadPool.QueueUserWorkItem(_ => continuation()); + } + } + } } } \ No newline at end of file diff --git a/test/Stateless.Tests/TriggerWithParametersFixture.cs b/test/Stateless.Tests/TriggerWithParametersFixture.cs index 8d92f147..80b70cc8 100644 --- a/test/Stateless.Tests/TriggerWithParametersFixture.cs +++ b/test/Stateless.Tests/TriggerWithParametersFixture.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Xunit; namespace Stateless.Tests @@ -62,6 +63,19 @@ public void StateParameterIsNotAmbiguous() .PermitDynamicIf(pressTrigger, state => state); } + /// + /// issue #380 - default params on PermitIfDynamic lead to ambiguity at compile time... explicits work properly. + /// + [Fact] + public void StateParameterIsNotAmbiguousAsync() + { + var fsm = new StateMachine(State.A); + StateMachine.TriggerWithParameters pressTrigger = fsm.SetTriggerParameters(Trigger.X); + + fsm.Configure(State.A) + .PermitDynamicIfAsync(pressTrigger, state => Task.FromResult(state)); + } + [Fact] public void IncompatibleParameterListIsNotValid() {