diff --git a/CHANGELOG.md b/CHANGELOG.md index cfc1ed53..4d2d8e8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ 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.19.0 - 2025.08.22 +### Added + - Added methods to unregister from state machine events [#625] + - `OnTransitionedUnregister` and `OnTransitionedAsyncUnregister` for transition events + - `OnTransitionCompletedUnregister` and `OnTransitionCompletedAsyncUnregister` for transition completed events + - `UnregisterAllCallbacks` to unregister all callbacks at once +### Fixed + - Fixed transition precedence issue where substate transitions were not given priority over parent state transitions [#626] +### Changed + - Improved performance by replacing string concatenations with `StringBuilder` in graph generation [#622] + - Moved repeated string literal into `internal const string` for better maintainability [#622] + - Refactored reflection classes for better code organization and consistency [#623] + - Enhanced parameter conversion with additional test coverage [#623] + ## 5.18.0 - 2025.08.02 ### Added - Added support for `PermitIfAsync` and `PermitReentryIfAsync` methods to allow async guard conditions [#618], [#189] @@ -241,6 +255,10 @@ Version 5.10.0 is now listed as the newest, since it has the highest version num ### Removed ### Fixed +[#626]: https://github.com/dotnet-state-machine/stateless/pull/626 +[#625]: https://github.com/dotnet-state-machine/stateless/pull/625 +[#623]: https://github.com/dotnet-state-machine/stateless/pull/623 +[#622]: https://github.com/dotnet-state-machine/stateless/pull/622 [#618]: https://github.com/dotnet-state-machine/stateless/pull/618 [#610]: https://github.com/dotnet-state-machine/stateless/pull/610 [#604]: https://github.com/dotnet-state-machine/stateless/issues/604 diff --git a/README.md b/README.md index e28ec732..5762f513 100644 --- a/README.md +++ b/README.md @@ -212,16 +212,70 @@ Stateless supports 2 types of state machine events: #### State transition ```csharp +// Synchronously stateMachine.OnTransitioned((transition) => { }); + +// Asynchronously +stateMachine.OnTransitionedAsync((transition) => { return Task.FromResult(0); }); ``` This event will be invoked every time the state machine changes state. #### State machine transition completed ```csharp +// Synchronously stateMachine.OnTransitionCompleted((transition) => { }); + +// Asynchronously +stateMachine.OnTransitionCompletedAsync((transition) => { return Task.FromResult(0); }); ``` This event will be invoked at the very end of the trigger handling, after the last entry action has been executed. +--- + +In addition to this, Stateless also provides you with the ability to unregister from state machine events in 3 ways. + * State transition unregister (sync/async) + * State machine transition completed (sync/async) + * State machine unregister from all (sync and async) + + +#### State machine transition unregister (synchronous) +```csharp +// Keep a reference to the synchronous callback action we want to unregister later. +Action transitionCallbackAction = (transition) => { }; +stateMachine.OnTransitionedUnregister(transitionCallbackAction); +``` +This method will unregister the specified action callback from the transition event. + +#### State machine transition unregister (asynchronous) +```csharp +// Keep a reference to the asynchronous callback function we want to unregister later. +Func transitionAsyncCallback => (transition) => { return Task.FromResult(0); }; +stateMachine.OnTransitionedAsyncUnregister(transitionAsyncCallback); +```` +This method will unregister the specified async function callback from the transition event. + +#### State machine transition completed unregister (synchronous) +```csharp +// Keep a reference to the synchronous callback action we want to unregister later. +Action transitionCompletedCallbackAction = (transition) => { }); +stateMachine.OnTransitionCompletedUnregister(transitionCompletedCallbackAction); +``` +This method will unregister the specified action callback from the transition completed event. + +#### State machine transition completed unregister (asynchronous) +```csharp +// Keep a reference to to the asynchronous callback function we want to unregister later. +Func transitionCompletedAsyncCallback => (transition) => { return Task.FromResult(0); }); +stateMachine.OnTransitionCompletedAsyncUnregister(transitionCompletedAsyncCallback); +``` +This method will unregister the specified async function callback from the transition completed event. + +#### Unregister all registered callbacks (sync and async) +```csharp +stateMachine.UnregisterAllCallbacks(); +``` +This method will unregister all synchronous and asynchronously registered callbacks from the state machine. + ### Export to DOT graph It can be useful to visualize state machines on runtime. With this approach the code is the authoritative source and state diagrams are by-products which are always up to date. diff --git a/example/OnOffExample/Program.cs b/example/OnOffExample/Program.cs index c887fff0..c40a5c2b 100644 --- a/example/OnOffExample/Program.cs +++ b/example/OnOffExample/Program.cs @@ -1,5 +1,6 @@ using System; using Stateless; +using Stateless.Graph; namespace OnOffExample { @@ -9,6 +10,11 @@ namespace OnOffExample /// class Program { + static void TransitionAnnounce(StateMachine.Transition transition) + { + Console.WriteLine($"State Machine Transitioning from '{transition.Source}' to '{transition.Destination}'"); + } + static void Main(string[] args) { const string on = "On"; @@ -28,9 +34,31 @@ static void Main(string[] args) { Console.WriteLine("Switch is in state: " + onOffSwitch.State); var pressed = Console.ReadKey(true).KeyChar; - + // Check if user wants to exit - if (pressed != space) break; + if (pressed != space) + { + // Before exiting this is how you can safely register and unregister from transition events. + // Keys 'r' or 'R' = Register for transition events with double subscription prevention built-in. + // Keys 'u' or 'U' = Unregister from transition events to prevent memory leaks in long running applications. + switch (pressed) + { + case 'r': + case 'R': + onOffSwitch.OnTransitioned(TransitionAnnounce); + Console.WriteLine("Now subscribed to transition events.."); + continue; + + case 'u': + case 'U': + onOffSwitch.OnTransitionedUnregister(TransitionAnnounce); + Console.WriteLine("Successfully unsubscribed from transition events.."); + continue; + } + + Console.WriteLine("Exiting program"); + break; + } // Use the Fire method with the trigger as payload to supply the state machine with an event. // The state machine will react according to its configuration. diff --git a/src/Stateless/Graph/GraphStyleBase.cs b/src/Stateless/Graph/GraphStyleBase.cs index 0b79c5df..881a5d36 100644 --- a/src/Stateless/Graph/GraphStyleBase.cs +++ b/src/Stateless/Graph/GraphStyleBase.cs @@ -64,8 +64,11 @@ public abstract class GraphStyleBase /// Description of all transitions, in the desired format. public virtual List FormatAllTransitions(List transitions) { - List lines = new List(); - if (transitions == null) return lines; + if (transitions == null) + return new List(); + + // Eagerly set the initial capacity to minimize re-allocation of internal array. + List lines = new List(transitions.Count); foreach (var transit in transitions) { @@ -84,26 +87,23 @@ public virtual List FormatAllTransitions(List transitions) stay.SourceState.NodeName, stay.Guards.Select(x => x.Description)); } } - else + else if (transit is FixedTransition fix) { - if (transit is FixedTransition fix) - { - line = FormatOneTransition(fix.SourceState.NodeName, fix.Trigger.UnderlyingTrigger.ToString(), + line = FormatOneTransition(fix.SourceState.NodeName, fix.Trigger.UnderlyingTrigger.ToString(), fix.DestinationEntryActions.Select(x => x.Method.Description), fix.DestinationState.NodeName, fix.Guards.Select(x => x.Description)); - } - else - { - if (transit is DynamicTransition dyn) - { - line = FormatOneTransition(dyn.SourceState.NodeName, dyn.Trigger.UnderlyingTrigger.ToString(), + } + else if (transit is DynamicTransition dyn) + { + line = FormatOneTransition(dyn.SourceState.NodeName, dyn.Trigger.UnderlyingTrigger.ToString(), dyn.DestinationEntryActions.Select(x => x.Method.Description), dyn.DestinationState.NodeName, new List { dyn.Criterion }); - } - else - throw new ArgumentException("Unexpected transition type"); - } } + else + { + throw new ArgumentException("Unexpected transition type"); + } + if (line != null) lines.Add(line); } diff --git a/src/Stateless/Graph/MermaidGraphStyle.cs b/src/Stateless/Graph/MermaidGraphStyle.cs index 7080c080..90eefe18 100644 --- a/src/Stateless/Graph/MermaidGraphStyle.cs +++ b/src/Stateless/Graph/MermaidGraphStyle.cs @@ -66,18 +66,21 @@ public override string FormatOneState(State state) public override string GetPrefix() { BuildSanitizedNamedStateMap(); - string prefix = "stateDiagram-v2"; + + StringBuilder sb = new StringBuilder("stateDiagram-v2"); if (_direction.HasValue) { - prefix += $"{Environment.NewLine}\tdirection {GetDirectionCode(_direction.Value)}"; + sb.AppendLine(); + sb.Append($"\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}"; + sb.AppendLine(); + sb.Append($"\t{state.Key} : {state.Value.StateName}"); } - return prefix; + return sb.ToString(); } /// @@ -91,25 +94,29 @@ public override string GetInitialTransition(StateInfo initialState) /// public override string FormatOneTransition(string sourceNodeName, string trigger, IEnumerable actions, string destinationNodeName, IEnumerable guards) { - string label = trigger ?? ""; + StringBuilder sb = new StringBuilder(trigger ?? string.Empty); if (actions?.Count() > 0) - label += " / " + string.Join(", ", actions); + { + sb.Append(" / "); + sb.Append(string.Join(", ", actions)); + } if (guards.Any()) { foreach (var info in guards) { - if (label.Length > 0) - label += " "; - label += "[" + info + "]"; + if (sb.Length > 0) + sb.Append(" "); + + sb.Append("[" + info + "]"); } } var sanitizedSourceNodeName = GetSanitizedStateName(sourceNodeName); var sanitizedDestinationNodeName = GetSanitizedStateName(destinationNodeName); - return FormatOneLine(sanitizedSourceNodeName, sanitizedDestinationNodeName, label); + return FormatOneLine(sanitizedSourceNodeName, sanitizedDestinationNodeName, sb.ToString()); } internal string FormatOneLine(string fromNodeName, string toNodeName, string label) diff --git a/src/Stateless/Graph/StateGraph.cs b/src/Stateless/Graph/StateGraph.cs index 7f335f68..29884e2c 100644 --- a/src/Stateless/Graph/StateGraph.cs +++ b/src/Stateless/Graph/StateGraph.cs @@ -1,5 +1,8 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Text; using Stateless.Reflection; namespace Stateless.Graph @@ -58,12 +61,12 @@ public StateGraph(StateMachineInfo machineInfo) /// public string ToGraph(GraphStyleBase style) { - string dirgraphText = style.GetPrefix(); + StringBuilder sb = new StringBuilder(style.GetPrefix()); // Start with the clusters foreach (var state in States.Values.Where(x => x is SuperState)) { - dirgraphText += style.FormatOneCluster((SuperState)state); + sb.Append(style.FormatOneCluster((SuperState)state)); } // Next process all non-cluster states @@ -71,24 +74,28 @@ public string ToGraph(GraphStyleBase style) { if (state is SuperState || state is Decision || state.SuperState != null) continue; - dirgraphText += style.FormatOneState(state); + + sb.Append(style.FormatOneState(state)); } // Finally, add decision nodes foreach (var dec in Decisions) { - dirgraphText += style.FormatOneDecisionNode(dec.NodeName, dec.Method.Description); + sb.Append(style.FormatOneDecisionNode(dec.NodeName, dec.Method.Description)); } // now build behaviours List transits = style.FormatAllTransitions(Transitions); foreach (var transit in transits) - dirgraphText += System.Environment.NewLine + transit; + { + sb.Append(Environment.NewLine); + sb.Append(transit); + } // Add initial transition if present - dirgraphText += style.GetInitialTransition(initialState); + sb.Append(style.GetInitialTransition(initialState)); - return dirgraphText; + return sb.ToString(); } /// @@ -202,8 +209,9 @@ void AddSingleStates(StateMachineInfo machineInfo) { foreach (var stateInfo in machineInfo.States) { - if (!States.ContainsKey(stateInfo.UnderlyingState.ToString())) - States[stateInfo.UnderlyingState.ToString()] = new State(stateInfo); + string underlyingState = stateInfo.UnderlyingState.ToString(); + if (!States.ContainsKey(underlyingState)) + States[underlyingState] = new State(stateInfo); } } @@ -225,14 +233,15 @@ void AddSubstates(SuperState superState, IEnumerable substates) { foreach (var subState in substates) { - if (States.ContainsKey(subState.UnderlyingState.ToString())) + string underlyingState = subState.UnderlyingState.ToString(); + if (States.ContainsKey(underlyingState)) { // This shouldn't happen } else if (subState.Substates.Any()) { SuperState sub = new SuperState(subState); - States[subState.UnderlyingState.ToString()] = sub; + States[underlyingState] = sub; superState.SubStates.Add(sub); sub.SuperState = superState; AddSubstates(sub, subState.Substates); @@ -240,7 +249,7 @@ void AddSubstates(SuperState superState, IEnumerable substates) else { State sub = new State(subState); - States[subState.UnderlyingState.ToString()] = sub; + States[underlyingState] = sub; superState.SubStates.Add(sub); sub.SuperState = superState; } diff --git a/src/Stateless/Graph/UmlDotGraphStyle.cs b/src/Stateless/Graph/UmlDotGraphStyle.cs index 0e465681..5a9d3614 100644 --- a/src/Stateless/Graph/UmlDotGraphStyle.cs +++ b/src/Stateless/Graph/UmlDotGraphStyle.cs @@ -70,17 +70,16 @@ public override string FormatOneState(State state) if (state.EntryActions.Count == 0 && state.ExitActions.Count == 0) return $"\"{escapedStateName}\" [label=\"{escapedStateName}\"];{Environment.NewLine}"; - string f = $"\"{escapedStateName}\" [label=\"{escapedStateName}|"; + StringBuilder sb = new StringBuilder($"\"{escapedStateName}\" [label=\"{escapedStateName}|"); List es = new List(); es.AddRange(state.EntryActions.Select(act => "entry / " + EscapeLabel(act))); es.AddRange(state.ExitActions.Select(act => "exit / " + EscapeLabel(act))); - f += string.Join("\\n", es); + sb.Append(string.Join("\\n", es)); + sb.Append($"\"];{Environment.NewLine}"); - f += $"\"];{Environment.NewLine}"; - - return f; + return sb.ToString(); } /// @@ -90,22 +89,26 @@ public override string FormatOneState(State state) /// public override string FormatOneTransition(string sourceNodeName, string trigger, IEnumerable actions, string destinationNodeName, IEnumerable guards) { - string label = trigger ?? ""; + StringBuilder sb = new StringBuilder(trigger ?? string.Empty); if (actions?.Count() > 0) - label += " / " + string.Join(", ", actions); + { + sb.Append(" / "); + sb.Append(string.Join(", ", actions)); + } if (guards.Any()) { foreach (var info in guards) { - if (label.Length > 0) - label += " "; - label += "[" + info + "]"; + if (sb.Length > 0) + sb.Append(" "); + + sb.Append("[" + info + "]"); } } - return FormatOneLine(sourceNodeName, destinationNodeName, label); + return FormatOneLine(sourceNodeName, destinationNodeName, sb.ToString()); } /// @@ -126,15 +129,19 @@ public override string FormatOneDecisionNode(string nodeName, string label) public override string GetInitialTransition(StateInfo initialState) { var initialStateName = initialState.UnderlyingState.ToString(); - string dirgraphText = Environment.NewLine + $" init [label=\"\", shape=point];"; - dirgraphText += Environment.NewLine + $" init -> \"{EscapeLabel(initialStateName)}\"[style = \"solid\"]"; - dirgraphText += Environment.NewLine + "}"; + StringBuilder sb = new StringBuilder(); + sb.AppendLine(); + sb.Append($" init [label=\"\", shape=point];"); + sb.AppendLine(); + sb.Append($" init -> \"{EscapeLabel(initialStateName)}\"[style = \"solid\"]"); + sb.AppendLine(); + sb.Append("}"); - return dirgraphText; + return sb.ToString(); } - internal string FormatOneLine(string fromNodeName, string toNodeName, string label) + internal static string FormatOneLine(string fromNodeName, string toNodeName, string label) { return $"\"{EscapeLabel(fromNodeName)}\" -> \"{EscapeLabel(toNodeName)}\" [style=\"solid\", label=\"{EscapeLabel(label)}\"];"; } diff --git a/src/Stateless/OnTransitionedEvent.cs b/src/Stateless/OnTransitionedEvent.cs index 4ede1525..95ca46e9 100644 --- a/src/Stateless/OnTransitionedEvent.cs +++ b/src/Stateless/OnTransitionedEvent.cs @@ -6,11 +6,11 @@ namespace Stateless { public partial class StateMachine { - class OnTransitionedEvent + internal class OnTransitionedEvent { event Action _onTransitioned; readonly List> _onTransitionedAsync = new List>(); - + public void Invoke(Transition transition) { if (_onTransitionedAsync.Count != 0) @@ -33,13 +33,41 @@ public async Task InvokeAsync(Transition transition, bool retainSynchronizationC public void Register(Action action) { + _onTransitioned -= action; _onTransitioned += action; } public void Register(Func action) { + _onTransitionedAsync.Remove(action); _onTransitionedAsync.Add(action); } + + public void Unregister(Action action) + { + if (_onTransitioned != null) + { + _onTransitioned -= action; + } + } + + public void Unregister(Func action) + { + _onTransitionedAsync.Remove(action); + } + + public void UnregisterAll() + { + if (_onTransitioned != null) + { + foreach (Delegate eventHandler in _onTransitioned.GetInvocationList()) + { + _onTransitioned -= (Action)eventHandler; + } + } + + _onTransitionedAsync.Clear(); + } } } } diff --git a/src/Stateless/ParameterConversion.cs b/src/Stateless/ParameterConversion.cs index ff28c4f7..631390db 100644 --- a/src/Stateless/ParameterConversion.cs +++ b/src/Stateless/ParameterConversion.cs @@ -11,7 +11,7 @@ public static object Unpack(object[] args, Type argType, int index) if (args.Length == 0) return null; - if (args.Length <= index) + if (args.Length <= index || index < 0) throw new ArgumentException( string.Format(ParameterConversionResources.ArgOfTypeRequiredInPosition, argType, index)); diff --git a/src/Stateless/Reflection/DynamicTransitionInfo.cs b/src/Stateless/Reflection/DynamicTransitionInfo.cs index a8179256..ca12090a 100644 --- a/src/Stateless/Reflection/DynamicTransitionInfo.cs +++ b/src/Stateless/Reflection/DynamicTransitionInfo.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; namespace Stateless.Reflection { @@ -19,12 +20,12 @@ public DynamicStateInfo(string destinationState, string criterion) /// /// The name of the destination state /// - public string DestinationState { get; set; } + public string DestinationState { get; } /// /// The reason this destination state was chosen /// - public string Criterion { get; set; } + public string Criterion { get; } } /// @@ -80,15 +81,13 @@ public class DynamicTransitionInfo : TransitionInfo public static DynamicTransitionInfo Create(TTrigger trigger, IEnumerable guards, InvocationInfo selector, DynamicStateInfos possibleStates) { - var transition = new DynamicTransitionInfo + return new DynamicTransitionInfo { Trigger = new TriggerInfo(trigger), - GuardConditionsMethodDescriptions = guards ?? new List(), + GuardConditionsMethodDescriptions = guards ?? Array.Empty(), DestinationStateSelectorDescription = selector, PossibleDestinationStates = possibleStates // behaviour.PossibleDestinationStates?.Select(x => x.ToString()).ToArray() }; - - return transition; } private DynamicTransitionInfo() { } diff --git a/src/Stateless/Reflection/FixedTransitionInfo.cs b/src/Stateless/Reflection/FixedTransitionInfo.cs index 60275476..acd46896 100644 --- a/src/Stateless/Reflection/FixedTransitionInfo.cs +++ b/src/Stateless/Reflection/FixedTransitionInfo.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System; using System.Linq; namespace Stateless.Reflection @@ -10,16 +10,14 @@ public class FixedTransitionInfo : TransitionInfo { internal static FixedTransitionInfo Create(StateMachine.TriggerBehaviour behaviour, StateInfo destinationStateInfo) { - var transition = new FixedTransitionInfo + return new FixedTransitionInfo { Trigger = new TriggerInfo(behaviour.Trigger), DestinationState = destinationStateInfo, GuardConditionsMethodDescriptions = behaviour.Guard == null - ? new List() : behaviour.Guard.Conditions.Select(c => c.MethodDescription), + ? Array.Empty() : behaviour.Guard.Conditions.Select(c => c.MethodDescription), IsInternalTransition = behaviour is StateMachine.InternalTriggerBehaviour }; - - return transition; } private FixedTransitionInfo() { } diff --git a/src/Stateless/Reflection/IgnoredTransitionInfo.cs b/src/Stateless/Reflection/IgnoredTransitionInfo.cs index 6e669950..47607881 100644 --- a/src/Stateless/Reflection/IgnoredTransitionInfo.cs +++ b/src/Stateless/Reflection/IgnoredTransitionInfo.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System; using System.Linq; namespace Stateless.Reflection @@ -14,7 +14,7 @@ internal static IgnoredTransitionInfo Create(StateMachine() : behaviour.Guard.Conditions.Select(c => c.MethodDescription) + ? Array.Empty() : behaviour.Guard.Conditions.Select(c => c.MethodDescription) }; return transition; diff --git a/src/Stateless/Reflection/InvocationInfo.cs b/src/Stateless/Reflection/InvocationInfo.cs index 5cec4727..3dfb62d9 100644 --- a/src/Stateless/Reflection/InvocationInfo.cs +++ b/src/Stateless/Reflection/InvocationInfo.cs @@ -7,6 +7,8 @@ namespace Stateless.Reflection /// public class InvocationInfo { + private static readonly char[] methodNameChars = { '<', '>', '`' }; + readonly string _description; // _description can be null if user didn't specify a description /// @@ -64,8 +66,8 @@ public string Description if (_description != null) return _description; if (MethodName == null) - return ""; - if (MethodName.IndexOfAny(new char[] { '<', '>', '`' }) >= 0) + return SpecialConstants.NullString; + if (MethodName.IndexOfAny(methodNameChars) >= 0) return DefaultFunctionDescription; return MethodName; } diff --git a/src/Stateless/Reflection/StateInfo.cs b/src/Stateless/Reflection/StateInfo.cs index 02e9fa18..0f1c1ed8 100644 --- a/src/Stateless/Reflection/StateInfo.cs +++ b/src/Stateless/Reflection/StateInfo.cs @@ -176,7 +176,7 @@ public IEnumerable Transitions /// public override string ToString() { - return UnderlyingState?.ToString() ?? ""; + return UnderlyingState?.ToString() ?? SpecialConstants.NullString; } } } diff --git a/src/Stateless/Reflection/TriggerInfo.cs b/src/Stateless/Reflection/TriggerInfo.cs index befbb2a8..74b9334d 100644 --- a/src/Stateless/Reflection/TriggerInfo.cs +++ b/src/Stateless/Reflection/TriggerInfo.cs @@ -20,7 +20,7 @@ internal TriggerInfo(object underlyingTrigger) /// public override string ToString() { - return UnderlyingTrigger?.ToString() ?? ""; + return UnderlyingTrigger?.ToString() ?? SpecialConstants.NullString; } } } diff --git a/src/Stateless/SpecialConstants.cs b/src/Stateless/SpecialConstants.cs new file mode 100644 index 00000000..7b7a37b7 --- /dev/null +++ b/src/Stateless/SpecialConstants.cs @@ -0,0 +1,10 @@ +namespace Stateless +{ + internal static class SpecialConstants + { + /// + /// Represents a null string identifier. + /// + internal const string NullString = ""; + } +} diff --git a/src/Stateless/StateMachine.Async.cs b/src/Stateless/StateMachine.Async.cs index b7944822..3665c94d 100644 --- a/src/Stateless/StateMachine.Async.cs +++ b/src/Stateless/StateMachine.Async.cs @@ -202,7 +202,7 @@ async Task InternalFireQueuedAsync(TTrigger trigger, params object[] args) } } - async Task InternalFireOneAsync(TTrigger trigger, params object[] args) + private async Task InternalFireOneAsync(TTrigger trigger, params object[] args) { // If this is a trigger with parameters, we must validate the parameter(s) if (_triggerConfiguration.TryGetValue(trigger, out TriggerWithParameters configuration)) @@ -449,6 +449,30 @@ public void OnTransitionCompletedAsync(Func onTransitionAction if (onTransitionAction == null) throw new ArgumentNullException(nameof(onTransitionAction)); _onTransitionCompletedEvent.Register(onTransitionAction); } + + /// + /// Unregisters a previously registered callback to prevent further events from + /// being raised when the state machine transitions from one state into another. + /// + /// + /// + public void OnTransitionedAsyncUnregister(Func onTransitionAction) + { + if (onTransitionAction == null) throw new ArgumentNullException(nameof(onTransitionAction)); + _onTransitionedEvent.Unregister(onTransitionAction); + } + + /// + /// Unregisters a previously registered callback to prevent further events from + /// being raised when the state machine has completed its state transition. + /// + /// + /// + public void OnTransitionCompletedAsyncUnregister(Func onTransitionAction) + { + if (onTransitionAction == null) throw new ArgumentNullException(nameof(onTransitionAction)); + _onTransitionCompletedEvent.Unregister(onTransitionAction); + } } } diff --git a/src/Stateless/StateMachine.cs b/src/Stateless/StateMachine.cs index 3a0f9b3e..999297e2 100644 --- a/src/Stateless/StateMachine.cs +++ b/src/Stateless/StateMachine.cs @@ -821,5 +821,39 @@ public void OnTransitionCompleted(Action onTransitionAction) if (onTransitionAction == null) throw new ArgumentNullException(nameof(onTransitionAction)); _onTransitionCompletedEvent.Register(onTransitionAction); } + + /// + /// Unregisters a previously registered callback to prevent further events from + /// being raised when the state machine transitions from one state into another. + /// + /// + /// + public void OnTransitionedUnregister(Action onTransitionAction) + { + if (onTransitionAction == null) throw new ArgumentNullException(nameof(onTransitionAction)); + _onTransitionedEvent.Unregister(onTransitionAction); + } + + /// + /// Unregisters a previously registered callback to prevent further events from + /// being raised when the state machine has completed its state transition. + /// + /// + /// + public void OnTransitionCompletedUnregister(Action onTransitionAction) + { + if (onTransitionAction == null) throw new ArgumentNullException(nameof(onTransitionAction)); + _onTransitionCompletedEvent.Unregister(onTransitionAction); + } + + /// + /// Unregisters all callbacks currently registered with the state machine for + /// both "transitioned" and "transition completed" events. + /// + public void UnregisterAllCallbacks() + { + _onTransitionedEvent.UnregisterAll(); + _onTransitionCompletedEvent.UnregisterAll(); + } } -} +} \ No newline at end of file diff --git a/src/Stateless/StateRepresentation.Async.cs b/src/Stateless/StateRepresentation.Async.cs index 082b8848..50264dca 100644 --- a/src/Stateless/StateRepresentation.Async.cs +++ b/src/Stateless/StateRepresentation.Async.cs @@ -189,13 +189,17 @@ public async Task> GetPermittedTriggersAsync(params object[] args public async Task TryFindHandlerAsync(TTrigger trigger, object[] args) { - var localHandlerFound = await TryFindLocalHandlerAsync(trigger, args); + TriggerBehaviourResult superstateHandler = null; - var superstateHandlerFound = Superstate != null - ? await Superstate.TryFindHandlerAsync(trigger, args) - : null; + var localHandler = await TryFindLocalHandlerAsync(trigger, args); + if (localHandler == null) + { + superstateHandler = Superstate != null + ? await Superstate.TryFindHandlerAsync(trigger, args) + : null; + } - return superstateHandlerFound ?? localHandlerFound; + return superstateHandler ?? localHandler; } private async Task TryFindLocalHandlerAsync(TTrigger trigger, object[] args) diff --git a/src/Stateless/Stateless.csproj b/src/Stateless/Stateless.csproj index 45044c03..d5bf7718 100644 --- a/src/Stateless/Stateless.csproj +++ b/src/Stateless/Stateless.csproj @@ -8,7 +8,7 @@ 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.18.0 + 5.19.0 Stateless Contributors true true diff --git a/test/Stateless.Tests/OnTransitionedEventTests.cs b/test/Stateless.Tests/OnTransitionedEventTests.cs new file mode 100644 index 00000000..63c5a58f --- /dev/null +++ b/test/Stateless.Tests/OnTransitionedEventTests.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Xunit; +using static Stateless.StateMachine; + +namespace Stateless.Tests +{ + public class OnTransitionedEventTests : IDisposable + { + private readonly OnTransitionedEvent subject; + + private event Action onTransitioned; + private List> onTransitionedAsync; + + private Action registeredAction; + private Func registeredAsyncAction; + + private int transitionCounter = 0; + + public OnTransitionedEventTests() + { + subject = new OnTransitionedEvent(); + + registeredAction = (Transition _) => + { + Interlocked.Increment(ref transitionCounter); + }; + + registeredAsyncAction = (Transition _) => + { + Interlocked.Increment(ref transitionCounter); + return Task.FromResult(0); + }; + } + + public void Dispose() + { + subject.UnregisterAll(); + } + + private void InstantiateTransitionReferences() + { + FieldInfo[] subjectFields = subject.GetType().GetFields(BindingFlags.Instance | BindingFlags.NonPublic); + + onTransitioned = subjectFields[0].GetValue(subject) as Action; + onTransitionedAsync = subjectFields[1].GetValue(subject) as List>; + } + + [Fact] + public void Register_ShouldOnlyRegisterActionOnce_WhenCalled() + { + subject.Register(registeredAction); + subject.Register(registeredAction); + + InstantiateTransitionReferences(); + + Assert.Single(onTransitioned.GetInvocationList() ?? Array.Empty()); + } + + [Fact] + public void Register_ShouldRegisterDistinctActions_WhenCalled() + { + subject.Register(registeredAction); + subject.Register((Transition _) => { }); + + InstantiateTransitionReferences(); + + Assert.Equal(2, onTransitioned.GetInvocationList().Length); + } + + [Fact] + public void Register_ShouldOnlyRegisterFuncOnce_WhenCalled() + { + subject.Register(registeredAsyncAction); + subject.Register(registeredAsyncAction); + + InstantiateTransitionReferences(); + + Assert.Single(onTransitionedAsync); + } + + [Fact] + public void Register_ShouldRegisterDistinctFuncActions_WhenCalled() + { + subject.Register(registeredAsyncAction); + subject.Register((Transition _) => Task.FromResult(0)); + + InstantiateTransitionReferences(); + + Assert.Equal(2, onTransitionedAsync.Count); + } + + [Fact] + public void Unregister_ShouldNotInvokeActionTwice_WhenCalled() + { + subject.Register(registeredAction); + subject.Invoke(new Transition(null, null, null)); + + Assert.Equal(1, transitionCounter); + + subject.Unregister(registeredAction); + subject.Invoke(new Transition(null, null, null)); + + Assert.Equal(1, transitionCounter); + } + + [Fact] + public async Task Unregister_ShouldNotInvokeAsyncActionTwice_WhenCalled() + { + subject.Register(registeredAsyncAction); + await subject.InvokeAsync(new Transition(null, null, null), true); + + Assert.Equal(1, transitionCounter); + + subject.Unregister(registeredAsyncAction); + await subject.InvokeAsync(new Transition(null, null, null), true); + + Assert.Equal(1, transitionCounter); + } + + [Fact] + public void UnregisterAll_ShouldRemoveSyncAndAsyncActions_WhenCalled() + { + subject.Register(registeredAction); + subject.Register(registeredAsyncAction); + + InstantiateTransitionReferences(); + + Assert.Equal(1, onTransitioned.GetInvocationList().Length); + Assert.Single(onTransitionedAsync); + + subject.UnregisterAll(); + + InstantiateTransitionReferences(); + + Assert.Null(onTransitioned); + Assert.Empty(onTransitionedAsync); + } + + [Fact] + public void Invoke_ShouldCallRegisteredAction_WhenCalled() + { + subject.Register(registeredAction); + subject.Invoke(new Transition(null, null, null)); + subject.Invoke(new Transition(null, null, null)); + + Assert.Equal(2, transitionCounter); + } + + [Fact] + public void Invoke_ShouldThrowInvalidOperationException_WhenAsyncActionRegistered() + { + subject.Register(registeredAction); + subject.Register(registeredAsyncAction); + + Assert.Throws(() => subject.Invoke(new Transition(null, null, null))); + } + + [Fact] + public async Task InvokeAsync_ShouldCallSyncAction_WhenRegistered() + { + subject.Register(registeredAction); + subject.Register(registeredAsyncAction); + + await subject.InvokeAsync(new Transition(null, null, null), true); + + Assert.Equal(2, transitionCounter); + } + + [Fact] + public async Task InvokeAsync_ShouldNotCallSyncAction_WhenNotRegistered() + { + subject.Register(registeredAsyncAction); + + await subject.InvokeAsync(new Transition(null, null, null), true); + + Assert.Equal(1, transitionCounter); + } + } +} diff --git a/test/Stateless.Tests/ParameterConversionTests.cs b/test/Stateless.Tests/ParameterConversionTests.cs new file mode 100644 index 00000000..314f726d --- /dev/null +++ b/test/Stateless.Tests/ParameterConversionTests.cs @@ -0,0 +1,76 @@ +using System; +using Xunit; + +namespace Stateless.Tests +{ + public class ParameterConversionTests + { + private readonly object[] args = { 5, 10, 15 }; + + [Fact] + public void Unpack_ShouldReturnArg_WhenValidationSucceeds() + { + Assert.Equal(5, ParameterConversion.Unpack(args, typeof(int), 0)); + Assert.Equal(10, ParameterConversion.Unpack(args, typeof(int), 1)); + Assert.Equal(15, ParameterConversion.Unpack(args, typeof(int), 2)); + Assert.Null(ParameterConversion.Unpack(new object[] { "John", null, "Doe" }, typeof(string), 1)); + } + + [Fact] + public void Unpack_ShouldThrowArgumentNullException_WhenArgsIsNull() + { + Assert.Throws(() => ParameterConversion.Unpack(null, null, 0)); + } + + [Fact] + public void Unpack_ShouldReturnNull_WhenArgsLengthIsZero() + { + Assert.Null(ParameterConversion.Unpack(Array.Empty(), null, 0)); + } + + [Fact] + public void Unpack_ShouldThrowArgumentException_WhenArgsLengthLessThanIndex() + { + Assert.Throws(() => ParameterConversion.Unpack(args, typeof(int), 5)); + } + + [Fact] + public void Unpack_ShouldThrowArgumentException_WhenIndexLessThanZero() + { + Assert.Throws(() => ParameterConversion.Unpack(args, typeof(int), -1)); + } + + [Fact] + public void Unpack_ShouldThrowArgumentException_WhenTypeIsNotAssignable() + { + Assert.Throws(() => ParameterConversion.Unpack(args, typeof(char), 0)); + } + + [Fact] + public void Unpack_ShouldReturnDefault_When2ParameterMethodCalled() + { + Assert.Equal(0, ParameterConversion.Unpack(Array.Empty(), 0)); + Assert.Equal(char.MinValue, ParameterConversion.Unpack(Array.Empty(), 0)); + Assert.Equal(false, ParameterConversion.Unpack(Array.Empty(), 0)); + } + + [Fact] + public void Validate_ShouldThrowArgumentException_WhenArgsGreaterThanExpected() + { + Assert.Throws(() => ParameterConversion.Validate(args, new Type[] { null, null })); + } + + [Fact] + public void Validate_ShouldWorkWithoutException_WhenEverythingExpectedIsProvided() + { + try + { + ParameterConversion.Validate(args, new Type[] { typeof(int), typeof(int), typeof(int) }); + } + catch (Exception) + { + throw; + } + } + } +} diff --git a/test/Stateless.Tests/ReflectionFixture.cs b/test/Stateless.Tests/ReflectionFixture.cs index ef688823..9bb6b634 100644 --- a/test/Stateless.Tests/ReflectionFixture.cs +++ b/test/Stateless.Tests/ReflectionFixture.cs @@ -1033,7 +1033,7 @@ public void InvocationInfo_Description_Property_When_Method_Name_Is_Null_Returns { var invocationInfo = new InvocationInfo(null, null, InvocationInfo.Timing.Synchronous); - Assert.Equal("", invocationInfo.Description); + Assert.Equal(SpecialConstants.NullString, invocationInfo.Description); } } } diff --git a/test/Stateless.Tests/TransitionFixture.cs b/test/Stateless.Tests/TransitionFixture.cs deleted file mode 100644 index e72b6510..00000000 --- a/test/Stateless.Tests/TransitionFixture.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Xunit; - -namespace Stateless.Tests -{ - public class TransitionFixture - { - [Fact] - public void IdentityTransitionIsNotChange() - { - StateMachine.Transition t = new StateMachine.Transition(1, 1, 0); - Assert.True(t.IsReentry); - } - - [Fact] - public void TransitioningTransitionIsChange() - { - StateMachine.Transition t = new StateMachine.Transition(1, 2, 0); - Assert.False(t.IsReentry); - } - - [Fact] - public void TestInternalIf() - { - // Verifies that only one internal action is executed - var machine = new StateMachine(1); - - machine.Configure(1) - .InternalTransitionIf( - 1, - t => { return true; }, - () => - { - Assert.True(true); - }) - .InternalTransitionIf( - 1, - u => { return false; }, - () => - { - Assert.True(false); - }); - - machine.Fire(1); - } - } -} diff --git a/test/Stateless.Tests/TransitionTests.cs b/test/Stateless.Tests/TransitionTests.cs new file mode 100644 index 00000000..1aa7aa80 --- /dev/null +++ b/test/Stateless.Tests/TransitionTests.cs @@ -0,0 +1,88 @@ +using System.Threading.Tasks; +using Xunit; + +namespace Stateless.Tests +{ + public class TransitionTests + { + [Fact] + public void IsReentry_ShouldBeTrue_WhenSourceAndDestinationAreEqual() + { + StateMachine.Transition t = new StateMachine.Transition(1, 1, 0); + Assert.True(t.IsReentry); + } + + [Fact] + public void IsReentry_ShouldBeFalse_WhenSourceAndDestinationDiffer() + { + StateMachine.Transition t = new StateMachine.Transition(1, 2, 0); + Assert.False(t.IsReentry); + } + + [Fact] + public void InternalTransitionIf_ShouldExecuteOnlyFirstMatchingAction() + { + // Verifies that only one internal action is executed + var machine = new StateMachine(1); + + machine.Configure(1) + .InternalTransitionIf( + 1, + t => { return true; }, + () => + { + Assert.True(true); + }) + .InternalTransitionIf( + 1, + u => { return false; }, + () => + { + Assert.True(false); + }); + + machine.Fire(1); + } + + [Fact] + public void GivenTriggerHandledOnSuperStateAndSubState_WhenTriggerFiredSync_ThenShouldUseSubstateTransition() + { + var sm = new StateMachine(State.A); + sm + .Configure(State.A) + .Permit(Trigger.X, State.B); + + // Overrides the superstate transition + sm + .Configure(State.B) + .SubstateOf(State.A) + .Permit(Trigger.X, State.C); + + sm.Fire(Trigger.X); + Assert.Equal(State.B, sm.State); + + sm.Fire(Trigger.X); + Assert.Equal(State.C, sm.State); // WORKS! + } + + [Fact] + public async Task GivenTriggerHandledOnSuperStateAndSubState_WhenTriggerFiredAsync_ThenShouldUseSubstateTransitionAsync() + { + var sm = new StateMachine(State.A); + sm + .Configure(State.A) + .Permit(Trigger.X, State.B); + + sm + .Configure(State.B) + .SubstateOf(State.A) + .Permit(Trigger.X, State.C); + + await sm.FireAsync(Trigger.X); + Assert.Equal(State.B, sm.State); + + await sm.FireAsync(Trigger.X); + Assert.Equal(State.C, sm.State); + } + } +}