From 0c11ccb6f1dd4790b28ec5319f6dd37401fdb78d Mon Sep 17 00:00:00 2001 From: PolarGoose <35307286+PolarGoose@users.noreply.github.com> Date: Sun, 4 Feb 2024 19:21:15 +0100 Subject: [PATCH 01/17] * Fix a potential NullReferenceException in the InvocationInfo class * Make FireAsync_TriggerWithMoreThanThreeParameters unit test culturally invariant. Otherwise it fails on some system locales. --- src/Stateless/Reflection/InvocationInfo.cs | 4 +++- test/Stateless.Tests/AsyncActionsFixture.cs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Stateless/Reflection/InvocationInfo.cs b/src/Stateless/Reflection/InvocationInfo.cs index 9f0270fa..5cec4727 100644 --- a/src/Stateless/Reflection/InvocationInfo.cs +++ b/src/Stateless/Reflection/InvocationInfo.cs @@ -63,9 +63,11 @@ public string Description { if (_description != null) return _description; + if (MethodName == null) + return ""; if (MethodName.IndexOfAny(new char[] { '<', '>', '`' }) >= 0) return DefaultFunctionDescription; - return MethodName ?? ""; + return MethodName; } } diff --git a/test/Stateless.Tests/AsyncActionsFixture.cs b/test/Stateless.Tests/AsyncActionsFixture.cs index 79ff95f5..d175183b 100644 --- a/test/Stateless.Tests/AsyncActionsFixture.cs +++ b/test/Stateless.Tests/AsyncActionsFixture.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using Xunit; +using System.Globalization; namespace Stateless.Tests { @@ -544,7 +545,8 @@ public async Task OnEntryFromAsync_WhenEnteringByAnotherTrigger_InvokesAction() [Fact] public async Task FireAsync_TriggerWithMoreThanThreeParameters() { - const string expectedParam = "42-Stateless-True-420.69-Y"; + var decimalSeparator = CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator; + string expectedParam = $"42-Stateless-True-420{decimalSeparator}69-Y"; string actualParam = null; var sm = new StateMachine(State.A); From 382662894c8149828b394f027357a2595fec5df2 Mon Sep 17 00:00:00 2001 From: rlittlesii <6969701+RLittlesII@users.noreply.github.com> Date: Wed, 10 Apr 2024 16:59:02 -0500 Subject: [PATCH 02/17] Add FireAsync(TTrigger, params object[]) overload The ability for this was recently added for TriggersWithParameters. Need similar functionality to reduce the need for reflection to get to the internal method. --- src/Stateless/StateMachine.Async.cs | 13 ++++++++++++ test/Stateless.Tests/AsyncActionsFixture.cs | 23 +++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/Stateless/StateMachine.Async.cs b/src/Stateless/StateMachine.Async.cs index c4e61e98..846842a5 100644 --- a/src/Stateless/StateMachine.Async.cs +++ b/src/Stateless/StateMachine.Async.cs @@ -46,6 +46,19 @@ public Task FireAsync(TTrigger trigger) return InternalFireAsync(trigger, new object[0]); } + /// + /// Transition from the current state via the specified trigger in async fashion. + /// The target state is determined by the configuration of the current state. + /// Actions associated with leaving the current state and entering the new one + /// will be invoked. + /// + /// The trigger to fire. + /// A variable-length parameters list containing arguments. + public Task FireAsync(TTrigger trigger, params object[] args) + { + return InternalFireAsync(trigger, args); + } + /// /// Transition from the current state via the specified trigger in async fashion. /// The target state is determined by the configuration of the current state. diff --git a/test/Stateless.Tests/AsyncActionsFixture.cs b/test/Stateless.Tests/AsyncActionsFixture.cs index 79ff95f5..e74d3e76 100644 --- a/test/Stateless.Tests/AsyncActionsFixture.cs +++ b/test/Stateless.Tests/AsyncActionsFixture.cs @@ -541,6 +541,29 @@ public async Task OnEntryFromAsync_WhenEnteringByAnotherTrigger_InvokesAction() Assert.False(wasInvoked); } + [Fact] + public async Task FireAsyncTriggerWithParametersArray() + { + const string expectedParam = "42-Stateless-True-420.69-Y"; + string actualParam = null; + + var sm = new StateMachine(State.A); + + sm.Configure(State.A) + .Permit(Trigger.X, State.B); + + sm.Configure(State.B) + .OnEntryAsync(t => + { + actualParam = string.Join("-", t.Parameters); + return Task.CompletedTask; + }); + + await sm.FireAsync(Trigger.X, 42, "Stateless", true, 420.69, Trigger.Y); + + Assert.Equal(expectedParam, actualParam); + } + [Fact] public async Task FireAsync_TriggerWithMoreThanThreeParameters() { From 27886d0c28bad8e0b0ea8381b378beebb55f46b9 Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Fri, 19 Apr 2024 21:19:34 +0100 Subject: [PATCH 03/17] #565 Permit state reentry from dynamic transitions. --- src/Stateless/StateConfiguration.cs | 130 +++++++++++------ src/Stateless/StateMachine.cs | 11 +- .../DynamicTriggerBehaviourFixture.cs | 136 ++++++++++++++++-- 3 files changed, 223 insertions(+), 54 deletions(-) diff --git a/src/Stateless/StateConfiguration.cs b/src/Stateless/StateConfiguration.cs index beec0168..cb35f4be 100644 --- a/src/Stateless/StateConfiguration.cs +++ b/src/Stateless/StateConfiguration.cs @@ -1162,8 +1162,10 @@ public StateConfiguration SubstateOf(TState superstate) /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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 function to calculate the state /// Optional array of possible destination states (used by output formatters) /// The receiver. @@ -1190,8 +1192,10 @@ public StateConfiguration PermitDynamic(TTrigger trigger, Func destinati /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1222,8 +1226,10 @@ public StateConfiguration PermitDynamic(TriggerWithParameters trig /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1254,8 +1260,10 @@ public StateConfiguration PermitDynamic(TriggerWithParameters /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1288,8 +1296,10 @@ public StateConfiguration PermitDynamic(TriggerWithParamete /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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 @@ -1306,8 +1316,10 @@ public StateConfiguration PermitDynamicIf(TTrigger trigger, Func destina /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1332,8 +1344,10 @@ public StateConfiguration PermitDynamicIf(TTrigger trigger, Func destina /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1348,8 +1362,10 @@ public StateConfiguration PermitDynamicIf(TTrigger trigger, Func destina /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1373,8 +1389,10 @@ public StateConfiguration PermitDynamicIf(TTrigger trigger, Func destina /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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 @@ -1400,8 +1418,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParameters tr /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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 PermitDynamicIf(TriggerWithParameters trigger, Func destinationStateSelector) @@ -1414,8 +1434,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParameters tr /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1440,8 +1462,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParameters tr /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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 @@ -1469,8 +1493,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParameters /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1497,8 +1523,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParameters /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1528,8 +1556,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParame /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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 @@ -1558,8 +1588,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParame /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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 @@ -1585,8 +1617,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParameters tr /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1611,8 +1645,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParameters tr /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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 @@ -1640,8 +1676,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParameters /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1668,8 +1706,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParameters /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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 @@ -1677,7 +1717,7 @@ public StateConfiguration PermitDynamicIf(TriggerWithParametersThe receiver. /// Type of the first trigger argument. /// Type of the second trigger argument. - /// + /// Type of the third trigger argument. public StateConfiguration PermitDynamicIf(TriggerWithParameters trigger, Func destinationStateSelector, Func guard, string guardDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) { if (trigger == null) throw new ArgumentNullException(nameof(trigger)); @@ -1699,15 +1739,17 @@ public StateConfiguration PermitDynamicIf(TriggerWithParame /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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 PermitDynamicIf(TriggerWithParameters trigger, Func destinationStateSelector, Tuple, string>[] guards, Reflection.DynamicStateInfos possibleDestinationStates = null) { if (trigger == null) throw new ArgumentNullException(nameof(trigger)); diff --git a/src/Stateless/StateMachine.cs b/src/Stateless/StateMachine.cs index 5d5a0e5f..db0d808b 100644 --- a/src/Stateless/StateMachine.cs +++ b/src/Stateless/StateMachine.cs @@ -421,9 +421,16 @@ void InternalFireOne(TTrigger trigger, params object[] args) break; } case DynamicTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out var destination): - case TransitioningTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out destination): { - //If a trigger was found on a superstate that would cause unintended reentry, don't trigger. + // Handle transition, and set new state; reentry is permitted from dynamic trigger behaviours. + var transition = new Transition(source, destination, trigger, args); + HandleTransitioningTrigger(args, representativeState, transition); + + break; + } + case TransitioningTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out var destination): + { + // If a trigger was found on a superstate that would cause unintended reentry, don't trigger. if (source.Equals(destination)) break; diff --git a/test/Stateless.Tests/DynamicTriggerBehaviourFixture.cs b/test/Stateless.Tests/DynamicTriggerBehaviourFixture.cs index 2a792155..9fbf5d39 100644 --- a/test/Stateless.Tests/DynamicTriggerBehaviourFixture.cs +++ b/test/Stateless.Tests/DynamicTriggerBehaviourFixture.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System; using Xunit; namespace Stateless.Tests @@ -6,7 +6,7 @@ namespace Stateless.Tests public class DynamicTriggerBehaviour { [Fact] - public void DestinationStateIsDynamic() + public void PermitDynamic_Selects_Expected_State() { var sm = new StateMachine(State.A); sm.Configure(State.A) @@ -18,7 +18,7 @@ public void DestinationStateIsDynamic() } [Fact] - public void DestinationStateIsCalculatedBasedOnTriggerParameters() + public void PermitDynamic_With_TriggerParameter_Selects_Expected_State() { var sm = new StateMachine(State.A); var trigger = sm.SetTriggerParameters(Trigger.X); @@ -31,17 +31,137 @@ public void DestinationStateIsCalculatedBasedOnTriggerParameters() } [Fact] - public void Sdfsf() + public void PermitDynamic_Permits_Reentry() { var sm = new StateMachine(State.A); - var trigger = sm.SetTriggerParameters(Trigger.X); + var onExitInvoked = false; + var onEntryInvoked = false; + var onEntryFromInvoked = false; + sm.Configure(State.A) + .PermitDynamic(Trigger.X, () => State.A) + .OnEntry(() => onEntryInvoked = true) + .OnEntryFrom(Trigger.X, () => onEntryFromInvoked = true) + .OnExit(() => onExitInvoked = true); + + sm.Fire(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 void PermitDynamic_Selects_Expected_State_Based_On_DestinationStateSelector_Function() + { + var sm = new StateMachine(State.A); + var value = 'C'; sm.Configure(State.A) - .PermitDynamicIf(trigger, (i) => i == 1 ? State.C : State.B, (i) => i == 1 ? true : false); + .PermitDynamic(Trigger.X, () => value == 'B' ? State.B : State.C); + + sm.Fire(Trigger.X); - // Should not throw - sm.GetPermittedTriggers().ToList(); + Assert.Equal(State.C, sm.State); + } + + [Fact] + public 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) + .PermitDynamicIf(trigger, (i) => i == 1 ? State.C : State.B, (i) => i == 1); sm.Fire(trigger, 1); + + Assert.Equal(State.C, sm.State); + } + + [Fact] + public 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).PermitDynamicIf( + trigger, + (i, j) => i == 1 && j == 2 ? State.C : State.B, + (i, j) => i == 1 && j == 2); + + sm.Fire(trigger, 1, 2); + + Assert.Equal(State.C, sm.State); + } + + [Fact] + public 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).PermitDynamicIf( + trigger, + (i, j, k) => i == 1 && j == 2 && k == 3 ? State.C : State.B, + (i, j, k) => i == 1 && j == 2 && k == 3); + + sm.Fire(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) + .PermitDynamicIf(trigger, (i) => 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).PermitDynamicIf( + trigger, + (i, j) => 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).PermitDynamicIf(trigger, + (i, j, k) => 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 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) + .PermitDynamicIf(Trigger.X, () => State.A, () => true) + .OnEntry(() => onEntryInvoked = true) + .OnEntryFrom(Trigger.X, () => onEntryFromInvoked = true) + .OnExit(() => onExitInvoked = true); + + sm.Fire(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); } } } From 1f833521ee2c2b8aac76d69eb0e9639c11fb3694 Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Fri, 19 Apr 2024 22:02:04 +0100 Subject: [PATCH 04/17] Housekeeping --- example/AlarmExample/Program.cs | 4 ++-- src/Stateless/StateMachine.cs | 4 ++-- test/Stateless.Tests/AsyncActionsFixture.cs | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/example/AlarmExample/Program.cs b/example/AlarmExample/Program.cs index 49a01a44..90ffd873 100644 --- a/example/AlarmExample/Program.cs +++ b/example/AlarmExample/Program.cs @@ -21,7 +21,7 @@ static void Main(string[] args) { Console.Write("> "); - input = Console.ReadLine(); + input = Console.ReadLine()!; if (!string.IsNullOrWhiteSpace(input)) switch (input.Split(" ")[0]) @@ -101,7 +101,7 @@ static void WriteFire(string input) Console.WriteLine($"{input.Split(" ")[1]} is not a valid AlarmCommand."); } } - catch (InvalidOperationException ex) + catch (InvalidOperationException) { Console.WriteLine($"{input.Split(" ")[1]} is not a valid AlarmCommand to the current state."); } diff --git a/src/Stateless/StateMachine.cs b/src/Stateless/StateMachine.cs index db0d808b..63c63636 100644 --- a/src/Stateless/StateMachine.cs +++ b/src/Stateless/StateMachine.cs @@ -6,13 +6,13 @@ namespace Stateless { /// - /// Enum for the different modes used when Fire-ing a trigger + /// Enum for the different modes used when Fireing a trigger /// public enum FiringMode { /// Use immediate mode when the queuing of trigger events are not needed. Care must be taken when using this mode, as there is no run-to-completion guaranteed. Immediate, - /// Use the queued Fire-ing mode when run-to-completion is required. This is the recommended mode. + /// Use the queued Fireing mode when run-to-completion is required. This is the recommended mode. Queued } diff --git a/test/Stateless.Tests/AsyncActionsFixture.cs b/test/Stateless.Tests/AsyncActionsFixture.cs index e74d3e76..48a67d39 100644 --- a/test/Stateless.Tests/AsyncActionsFixture.cs +++ b/test/Stateless.Tests/AsyncActionsFixture.cs @@ -544,7 +544,7 @@ public async Task OnEntryFromAsync_WhenEnteringByAnotherTrigger_InvokesAction() [Fact] public async Task FireAsyncTriggerWithParametersArray() { - const string expectedParam = "42-Stateless-True-420.69-Y"; + const string expectedParam = "42-Stateless-True-123.45-Y"; string actualParam = null; var sm = new StateMachine(State.A); @@ -559,7 +559,7 @@ public async Task FireAsyncTriggerWithParametersArray() return Task.CompletedTask; }); - await sm.FireAsync(Trigger.X, 42, "Stateless", true, 420.69, Trigger.Y); + await sm.FireAsync(Trigger.X, 42, "Stateless", true, 123.45, Trigger.Y); Assert.Equal(expectedParam, actualParam); } @@ -567,7 +567,7 @@ public async Task FireAsyncTriggerWithParametersArray() [Fact] public async Task FireAsync_TriggerWithMoreThanThreeParameters() { - const string expectedParam = "42-Stateless-True-420.69-Y"; + const string expectedParam = "42-Stateless-True-123.45-Y"; string actualParam = null; var sm = new StateMachine(State.A); @@ -584,7 +584,7 @@ public async Task FireAsync_TriggerWithMoreThanThreeParameters() var parameterizedX = sm.SetTriggerParameters(Trigger.X, typeof(int), typeof(string), typeof(bool), typeof(double), typeof(Trigger)); - await sm.FireAsync(parameterizedX, 42, "Stateless", true, 420.69, Trigger.Y); + await sm.FireAsync(parameterizedX, 42, "Stateless", true, 123.45, Trigger.Y); Assert.Equal(expectedParam, actualParam); } From dec8c3d780d5a7c561f12e930e66fcabe2abb9f3 Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Sun, 21 Apr 2024 11:51:51 +0100 Subject: [PATCH 05/17] Fix localisation bug in tests. --- test/Stateless.Tests/AsyncActionsFixture.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/test/Stateless.Tests/AsyncActionsFixture.cs b/test/Stateless.Tests/AsyncActionsFixture.cs index 48a67d39..ebfcf293 100644 --- a/test/Stateless.Tests/AsyncActionsFixture.cs +++ b/test/Stateless.Tests/AsyncActionsFixture.cs @@ -1,14 +1,15 @@ #if TASKS using System; -using System.Threading.Tasks; using System.Collections.Generic; - +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Xunit; namespace Stateless.Tests { - public class AsyncActionsFixture { [Fact] @@ -555,11 +556,12 @@ public async Task FireAsyncTriggerWithParametersArray() sm.Configure(State.B) .OnEntryAsync(t => { - actualParam = string.Join("-", t.Parameters); + actualParam = string.Join("-", t.Parameters.Select(x => string.Format(CultureInfo.InvariantCulture, "{0}", x))); return Task.CompletedTask; }); await sm.FireAsync(Trigger.X, 42, "Stateless", true, 123.45, Trigger.Y); + Console.WriteLine(Thread.CurrentThread.CurrentCulture); Assert.Equal(expectedParam, actualParam); } @@ -578,7 +580,7 @@ public async Task FireAsync_TriggerWithMoreThanThreeParameters() sm.Configure(State.B) .OnEntryAsync(t => { - actualParam = string.Join("-", t.Parameters); + actualParam = string.Join("-", t.Parameters.Select(x => string.Format(CultureInfo.InvariantCulture, "{0}", x))); return Task.CompletedTask; }); From 18d71415efed88c77b5d41fbb22e3f68cd9b2650 Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Sun, 21 Apr 2024 11:55:10 +0100 Subject: [PATCH 06/17] Remove debug code. --- test/Stateless.Tests/AsyncActionsFixture.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/Stateless.Tests/AsyncActionsFixture.cs b/test/Stateless.Tests/AsyncActionsFixture.cs index ebfcf293..efb7208c 100644 --- a/test/Stateless.Tests/AsyncActionsFixture.cs +++ b/test/Stateless.Tests/AsyncActionsFixture.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Threading; using System.Threading.Tasks; using Xunit; @@ -561,7 +560,6 @@ public async Task FireAsyncTriggerWithParametersArray() }); await sm.FireAsync(Trigger.X, 42, "Stateless", true, 123.45, Trigger.Y); - Console.WriteLine(Thread.CurrentThread.CurrentCulture); Assert.Equal(expectedParam, actualParam); } From 7c6511cfbc82854ae064d90cc0daa4848750aba7 Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Mon, 22 Apr 2024 21:19:21 +0100 Subject: [PATCH 07/17] #565 Permit state reentry from dynamic transitions with FireAsync; retrofit #544 onto FireAsync. --- src/Stateless/StateMachine.Async.cs | 13 +- src/Stateless/StateMachine.cs | 60 +++---- test/Stateless.Tests/AsyncActionsFixture.cs | 22 +++ .../DynamicTriggerBehaviourAsyncFixture.cs | 168 ++++++++++++++++++ .../DynamicTriggerBehaviourFixture.cs | 2 +- 5 files changed, 233 insertions(+), 32 deletions(-) create mode 100644 test/Stateless.Tests/DynamicTriggerBehaviourAsyncFixture.cs diff --git a/src/Stateless/StateMachine.Async.cs b/src/Stateless/StateMachine.Async.cs index 846842a5..919dedda 100644 --- a/src/Stateless/StateMachine.Async.cs +++ b/src/Stateless/StateMachine.Async.cs @@ -220,8 +220,19 @@ async Task InternalFireOneAsync(TTrigger trigger, params object[] args) break; } case DynamicTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out var destination): - case TransitioningTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out destination): { + // 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 TransitioningTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out var destination): + { + // If a trigger was found on a superstate that would cause unintended reentry, don't trigger. + if (source.Equals(destination)) + break; + // Handle transition, and set new state var transition = new Transition(source, destination, trigger, args); await HandleTransitioningTriggerAsync(args, representativeState, transition); diff --git a/src/Stateless/StateMachine.cs b/src/Stateless/StateMachine.cs index 63c63636..121d6ede 100644 --- a/src/Stateless/StateMachine.cs +++ b/src/Stateless/StateMachine.cs @@ -47,7 +47,7 @@ private class QueuedTrigger /// /// A function that will be called to read the current state value. /// An action that will be called to write new state values. - public StateMachine(Func stateAccessor, Action stateMutator) :this(stateAccessor, stateMutator, FiringMode.Queued) + public StateMachine(Func stateAccessor, Action stateMutator) : this(stateAccessor, stateMutator, FiringMode.Queued) { } @@ -414,39 +414,39 @@ void InternalFireOne(TTrigger trigger, params object[] args) // Handle special case, re-entry in superstate // Check if it is an internal transition, or a transition from one state to another. case ReentryTriggerBehaviour handler: - { - // Handle transition, and set new state - var transition = new Transition(source, handler.Destination, trigger, args); - HandleReentryTrigger(args, representativeState, transition); - break; - } + { + // Handle transition, and set new state + var transition = new Transition(source, handler.Destination, trigger, args); + HandleReentryTrigger(args, representativeState, transition); + break; + } case DynamicTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out var destination): - { - // Handle transition, and set new state; reentry is permitted from dynamic trigger behaviours. - var transition = new Transition(source, destination, trigger, args); - HandleTransitioningTrigger(args, representativeState, transition); + { + // Handle transition, and set new state; reentry is permitted from dynamic trigger behaviours. + var transition = new Transition(source, destination, trigger, args); + HandleTransitioningTrigger(args, representativeState, transition); - break; - } - case TransitioningTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out var destination): - { - // If a trigger was found on a superstate that would cause unintended reentry, don't trigger. - if (source.Equals(destination)) break; + } + case TransitioningTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out var destination): + { + // If a trigger was found on a superstate that would cause unintended reentry, don't trigger. + if (source.Equals(destination)) + break; - // Handle transition, and set new state - var transition = new Transition(source, destination, trigger, args); - HandleTransitioningTrigger(args, representativeState, transition); + // Handle transition, and set new state + var transition = new Transition(source, destination, trigger, args); + HandleTransitioningTrigger(args, representativeState, transition); - break; - } + break; + } case InternalTriggerBehaviour _: - { - // Internal transitions does not update the current state, but must execute the associated action. - var transition = new Transition(source, source, trigger, args); - CurrentRepresentation.InternalAction(transition, args); - break; - } + { + // Internal transitions does not update the current state, but must execute the associated action. + var transition = new Transition(source, source, trigger, args); + CurrentRepresentation.InternalAction(transition, args); + break; + } default: throw new InvalidOperationException("State machine configuration incorrect, no handler for trigger."); } @@ -478,7 +478,7 @@ private void HandleReentryTrigger(object[] args, StateRepresentation representat State = representation.UnderlyingState; } - private void HandleTransitioningTrigger( object[] args, StateRepresentation representativeState, Transition transition) + private void HandleTransitioningTrigger(object[] args, StateRepresentation representativeState, Transition transition) { transition = representativeState.Exit(transition); @@ -499,7 +499,7 @@ private void HandleTransitioningTrigger( object[] args, StateRepresentation repr _onTransitionCompletedEvent.Invoke(new Transition(transition.Source, State, transition.Trigger, transition.Parameters)); } - private StateRepresentation EnterState(StateRepresentation representation, Transition transition, object [] args) + private StateRepresentation EnterState(StateRepresentation representation, Transition transition, object[] args) { // Enter the new state representation.Enter(transition, args); diff --git a/test/Stateless.Tests/AsyncActionsFixture.cs b/test/Stateless.Tests/AsyncActionsFixture.cs index efb7208c..aed8ba58 100644 --- a/test/Stateless.Tests/AsyncActionsFixture.cs +++ b/test/Stateless.Tests/AsyncActionsFixture.cs @@ -588,6 +588,28 @@ public async Task FireAsync_TriggerWithMoreThanThreeParameters() Assert.Equal(expectedParam, actualParam); } + + [Fact] + public async Task WhenInSubstate_TriggerSuperStateTwiceToSameSubstate_DoesNotReenterSubstate_Async() + { + var sm = new StateMachine(State.A); + var eCount = 0; + + sm.Configure(State.B) + .OnEntry(() => { eCount++; }) + .SubstateOf(State.C); + + sm.Configure(State.A) + .SubstateOf(State.C); + + sm.Configure(State.C) + .Permit(Trigger.X, State.B); + + await sm.FireAsync(Trigger.X); + await sm.FireAsync(Trigger.X); + + Assert.Equal(1, eCount); + } } } diff --git a/test/Stateless.Tests/DynamicTriggerBehaviourAsyncFixture.cs b/test/Stateless.Tests/DynamicTriggerBehaviourAsyncFixture.cs new file mode 100644 index 00000000..b2d09fee --- /dev/null +++ b/test/Stateless.Tests/DynamicTriggerBehaviourAsyncFixture.cs @@ -0,0 +1,168 @@ +using System; +using System.Threading.Tasks; +using Xunit; + +namespace Stateless.Tests +{ + public class DynamicTriggerBehaviourAsyncFixture + { + [Fact] + public async Task PermitDynamic_Selects_Expected_State_Async() + { + var sm = new StateMachine(State.A); + sm.Configure(State.A) + .PermitDynamic(Trigger.X, () => 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) + .PermitDynamic(trigger, i => 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) + .PermitDynamic(Trigger.X, () => 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) + .PermitDynamic(Trigger.X, () => 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) + .PermitDynamicIf(trigger, (i) => 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).PermitDynamicIf( + trigger, + (i, j) => 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).PermitDynamicIf( + trigger, + (i, j, k) => 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) + .PermitDynamicIf(trigger, (i) => i > 0 ? State.C : State.B, (i) => i == 2 ? true : false); + + 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).PermitDynamicIf( + trigger, + (i, j) => 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).PermitDynamicIf(trigger, + (i, j, k) => 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) + .PermitDynamicIf(Trigger.X, () => 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/DynamicTriggerBehaviourFixture.cs b/test/Stateless.Tests/DynamicTriggerBehaviourFixture.cs index 9fbf5d39..893a3aec 100644 --- a/test/Stateless.Tests/DynamicTriggerBehaviourFixture.cs +++ b/test/Stateless.Tests/DynamicTriggerBehaviourFixture.cs @@ -3,7 +3,7 @@ namespace Stateless.Tests { - public class DynamicTriggerBehaviour + public class DynamicTriggerBehaviourFixture { [Fact] public void PermitDynamic_Selects_Expected_State() From 750c8959457ba6dd75000bc372fc0091aa9048e5 Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Fri, 19 Apr 2024 21:19:34 +0100 Subject: [PATCH 08/17] #565 Permit state reentry from dynamic transitions. --- src/Stateless/StateConfiguration.cs | 130 +++++++++++------ src/Stateless/StateMachine.cs | 11 +- .../DynamicTriggerBehaviourFixture.cs | 136 ++++++++++++++++-- 3 files changed, 223 insertions(+), 54 deletions(-) diff --git a/src/Stateless/StateConfiguration.cs b/src/Stateless/StateConfiguration.cs index beec0168..cb35f4be 100644 --- a/src/Stateless/StateConfiguration.cs +++ b/src/Stateless/StateConfiguration.cs @@ -1162,8 +1162,10 @@ public StateConfiguration SubstateOf(TState superstate) /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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 function to calculate the state /// Optional array of possible destination states (used by output formatters) /// The receiver. @@ -1190,8 +1192,10 @@ public StateConfiguration PermitDynamic(TTrigger trigger, Func destinati /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1222,8 +1226,10 @@ public StateConfiguration PermitDynamic(TriggerWithParameters trig /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1254,8 +1260,10 @@ public StateConfiguration PermitDynamic(TriggerWithParameters /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1288,8 +1296,10 @@ public StateConfiguration PermitDynamic(TriggerWithParamete /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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 @@ -1306,8 +1316,10 @@ public StateConfiguration PermitDynamicIf(TTrigger trigger, Func destina /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1332,8 +1344,10 @@ public StateConfiguration PermitDynamicIf(TTrigger trigger, Func destina /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1348,8 +1362,10 @@ public StateConfiguration PermitDynamicIf(TTrigger trigger, Func destina /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1373,8 +1389,10 @@ public StateConfiguration PermitDynamicIf(TTrigger trigger, Func destina /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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 @@ -1400,8 +1418,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParameters tr /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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 PermitDynamicIf(TriggerWithParameters trigger, Func destinationStateSelector) @@ -1414,8 +1434,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParameters tr /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1440,8 +1462,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParameters tr /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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 @@ -1469,8 +1493,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParameters /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1497,8 +1523,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParameters /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1528,8 +1556,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParame /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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 @@ -1558,8 +1588,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParame /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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 @@ -1585,8 +1617,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParameters tr /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1611,8 +1645,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParameters tr /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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 @@ -1640,8 +1676,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParameters /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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. @@ -1668,8 +1706,10 @@ public StateConfiguration PermitDynamicIf(TriggerWithParameters /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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 @@ -1677,7 +1717,7 @@ public StateConfiguration PermitDynamicIf(TriggerWithParametersThe receiver. /// Type of the first trigger argument. /// Type of the second trigger argument. - /// + /// Type of the third trigger argument. public StateConfiguration PermitDynamicIf(TriggerWithParameters trigger, Func destinationStateSelector, Func guard, string guardDescription = null, Reflection.DynamicStateInfos possibleDestinationStates = null) { if (trigger == null) throw new ArgumentNullException(nameof(trigger)); @@ -1699,15 +1739,17 @@ public StateConfiguration PermitDynamicIf(TriggerWithParame /// dynamically by the supplied function. /// /// The accepted trigger. - /// Function to calculate the state - /// that the trigger will cause a transition to. + /// + /// 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 PermitDynamicIf(TriggerWithParameters trigger, Func destinationStateSelector, Tuple, string>[] guards, Reflection.DynamicStateInfos possibleDestinationStates = null) { if (trigger == null) throw new ArgumentNullException(nameof(trigger)); diff --git a/src/Stateless/StateMachine.cs b/src/Stateless/StateMachine.cs index 5d5a0e5f..db0d808b 100644 --- a/src/Stateless/StateMachine.cs +++ b/src/Stateless/StateMachine.cs @@ -421,9 +421,16 @@ void InternalFireOne(TTrigger trigger, params object[] args) break; } case DynamicTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out var destination): - case TransitioningTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out destination): { - //If a trigger was found on a superstate that would cause unintended reentry, don't trigger. + // Handle transition, and set new state; reentry is permitted from dynamic trigger behaviours. + var transition = new Transition(source, destination, trigger, args); + HandleTransitioningTrigger(args, representativeState, transition); + + break; + } + case TransitioningTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out var destination): + { + // If a trigger was found on a superstate that would cause unintended reentry, don't trigger. if (source.Equals(destination)) break; diff --git a/test/Stateless.Tests/DynamicTriggerBehaviourFixture.cs b/test/Stateless.Tests/DynamicTriggerBehaviourFixture.cs index 2a792155..9fbf5d39 100644 --- a/test/Stateless.Tests/DynamicTriggerBehaviourFixture.cs +++ b/test/Stateless.Tests/DynamicTriggerBehaviourFixture.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System; using Xunit; namespace Stateless.Tests @@ -6,7 +6,7 @@ namespace Stateless.Tests public class DynamicTriggerBehaviour { [Fact] - public void DestinationStateIsDynamic() + public void PermitDynamic_Selects_Expected_State() { var sm = new StateMachine(State.A); sm.Configure(State.A) @@ -18,7 +18,7 @@ public void DestinationStateIsDynamic() } [Fact] - public void DestinationStateIsCalculatedBasedOnTriggerParameters() + public void PermitDynamic_With_TriggerParameter_Selects_Expected_State() { var sm = new StateMachine(State.A); var trigger = sm.SetTriggerParameters(Trigger.X); @@ -31,17 +31,137 @@ public void DestinationStateIsCalculatedBasedOnTriggerParameters() } [Fact] - public void Sdfsf() + public void PermitDynamic_Permits_Reentry() { var sm = new StateMachine(State.A); - var trigger = sm.SetTriggerParameters(Trigger.X); + var onExitInvoked = false; + var onEntryInvoked = false; + var onEntryFromInvoked = false; + sm.Configure(State.A) + .PermitDynamic(Trigger.X, () => State.A) + .OnEntry(() => onEntryInvoked = true) + .OnEntryFrom(Trigger.X, () => onEntryFromInvoked = true) + .OnExit(() => onExitInvoked = true); + + sm.Fire(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 void PermitDynamic_Selects_Expected_State_Based_On_DestinationStateSelector_Function() + { + var sm = new StateMachine(State.A); + var value = 'C'; sm.Configure(State.A) - .PermitDynamicIf(trigger, (i) => i == 1 ? State.C : State.B, (i) => i == 1 ? true : false); + .PermitDynamic(Trigger.X, () => value == 'B' ? State.B : State.C); + + sm.Fire(Trigger.X); - // Should not throw - sm.GetPermittedTriggers().ToList(); + Assert.Equal(State.C, sm.State); + } + + [Fact] + public 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) + .PermitDynamicIf(trigger, (i) => i == 1 ? State.C : State.B, (i) => i == 1); sm.Fire(trigger, 1); + + Assert.Equal(State.C, sm.State); + } + + [Fact] + public 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).PermitDynamicIf( + trigger, + (i, j) => i == 1 && j == 2 ? State.C : State.B, + (i, j) => i == 1 && j == 2); + + sm.Fire(trigger, 1, 2); + + Assert.Equal(State.C, sm.State); + } + + [Fact] + public 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).PermitDynamicIf( + trigger, + (i, j, k) => i == 1 && j == 2 && k == 3 ? State.C : State.B, + (i, j, k) => i == 1 && j == 2 && k == 3); + + sm.Fire(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) + .PermitDynamicIf(trigger, (i) => 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).PermitDynamicIf( + trigger, + (i, j) => 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).PermitDynamicIf(trigger, + (i, j, k) => 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 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) + .PermitDynamicIf(Trigger.X, () => State.A, () => true) + .OnEntry(() => onEntryInvoked = true) + .OnEntryFrom(Trigger.X, () => onEntryFromInvoked = true) + .OnExit(() => onExitInvoked = true); + + sm.Fire(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); } } } From 1cf007c567b67db0a77ba6cbeb4c3f124e4ea32e Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Fri, 19 Apr 2024 22:02:04 +0100 Subject: [PATCH 09/17] Merged from dev --- example/AlarmExample/Program.cs | 4 ++-- src/Stateless/StateMachine.cs | 4 ++-- test/Stateless.Tests/AsyncActionsFixture.cs | 9 ++++----- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/example/AlarmExample/Program.cs b/example/AlarmExample/Program.cs index 49a01a44..90ffd873 100644 --- a/example/AlarmExample/Program.cs +++ b/example/AlarmExample/Program.cs @@ -21,7 +21,7 @@ static void Main(string[] args) { Console.Write("> "); - input = Console.ReadLine(); + input = Console.ReadLine()!; if (!string.IsNullOrWhiteSpace(input)) switch (input.Split(" ")[0]) @@ -101,7 +101,7 @@ static void WriteFire(string input) Console.WriteLine($"{input.Split(" ")[1]} is not a valid AlarmCommand."); } } - catch (InvalidOperationException ex) + catch (InvalidOperationException) { Console.WriteLine($"{input.Split(" ")[1]} is not a valid AlarmCommand to the current state."); } diff --git a/src/Stateless/StateMachine.cs b/src/Stateless/StateMachine.cs index db0d808b..63c63636 100644 --- a/src/Stateless/StateMachine.cs +++ b/src/Stateless/StateMachine.cs @@ -6,13 +6,13 @@ namespace Stateless { /// - /// Enum for the different modes used when Fire-ing a trigger + /// Enum for the different modes used when Fireing a trigger /// public enum FiringMode { /// Use immediate mode when the queuing of trigger events are not needed. Care must be taken when using this mode, as there is no run-to-completion guaranteed. Immediate, - /// Use the queued Fire-ing mode when run-to-completion is required. This is the recommended mode. + /// Use the queued Fireing mode when run-to-completion is required. This is the recommended mode. Queued } diff --git a/test/Stateless.Tests/AsyncActionsFixture.cs b/test/Stateless.Tests/AsyncActionsFixture.cs index 397b5e4e..8295c4c4 100644 --- a/test/Stateless.Tests/AsyncActionsFixture.cs +++ b/test/Stateless.Tests/AsyncActionsFixture.cs @@ -545,7 +545,7 @@ public async Task OnEntryFromAsync_WhenEnteringByAnotherTrigger_InvokesAction() [Fact] public async Task FireAsyncTriggerWithParametersArray() { - const string expectedParam = "42-Stateless-True-420.69-Y"; + const string expectedParam = "42-Stateless-True-123.45-Y"; string actualParam = null; var sm = new StateMachine(State.A); @@ -560,7 +560,7 @@ public async Task FireAsyncTriggerWithParametersArray() return Task.CompletedTask; }); - await sm.FireAsync(Trigger.X, 42, "Stateless", true, 420.69, Trigger.Y); + await sm.FireAsync(Trigger.X, 42, "Stateless", true, 123.45, Trigger.Y); Assert.Equal(expectedParam, actualParam); } @@ -568,8 +568,7 @@ public async Task FireAsyncTriggerWithParametersArray() [Fact] public async Task FireAsync_TriggerWithMoreThanThreeParameters() { - var decimalSeparator = CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator; - string expectedParam = $"42-Stateless-True-420{decimalSeparator}69-Y"; + const string expectedParam = "42-Stateless-True-123.45-Y"; string actualParam = null; var sm = new StateMachine(State.A); @@ -586,7 +585,7 @@ public async Task FireAsync_TriggerWithMoreThanThreeParameters() var parameterizedX = sm.SetTriggerParameters(Trigger.X, typeof(int), typeof(string), typeof(bool), typeof(double), typeof(Trigger)); - await sm.FireAsync(parameterizedX, 42, "Stateless", true, 420.69, Trigger.Y); + await sm.FireAsync(parameterizedX, 42, "Stateless", true, 123.45, Trigger.Y); Assert.Equal(expectedParam, actualParam); } From 75494cb3058becdb5059c83e78c2ef608b6ada03 Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Sun, 21 Apr 2024 11:51:51 +0100 Subject: [PATCH 10/17] Fix localisation bug in tests. --- test/Stateless.Tests/AsyncActionsFixture.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/test/Stateless.Tests/AsyncActionsFixture.cs b/test/Stateless.Tests/AsyncActionsFixture.cs index 8295c4c4..51f8398e 100644 --- a/test/Stateless.Tests/AsyncActionsFixture.cs +++ b/test/Stateless.Tests/AsyncActionsFixture.cs @@ -1,15 +1,16 @@ #if TASKS using System; -using System.Threading.Tasks; using System.Collections.Generic; - +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Xunit; using System.Globalization; namespace Stateless.Tests { - public class AsyncActionsFixture { [Fact] @@ -556,11 +557,12 @@ public async Task FireAsyncTriggerWithParametersArray() sm.Configure(State.B) .OnEntryAsync(t => { - actualParam = string.Join("-", t.Parameters); + actualParam = string.Join("-", t.Parameters.Select(x => string.Format(CultureInfo.InvariantCulture, "{0}", x))); return Task.CompletedTask; }); await sm.FireAsync(Trigger.X, 42, "Stateless", true, 123.45, Trigger.Y); + Console.WriteLine(Thread.CurrentThread.CurrentCulture); Assert.Equal(expectedParam, actualParam); } @@ -579,7 +581,7 @@ public async Task FireAsync_TriggerWithMoreThanThreeParameters() sm.Configure(State.B) .OnEntryAsync(t => { - actualParam = string.Join("-", t.Parameters); + actualParam = string.Join("-", t.Parameters.Select(x => string.Format(CultureInfo.InvariantCulture, "{0}", x))); return Task.CompletedTask; }); From 4c36aed7e3e6106defa4a84b101ea5a3cc545788 Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Sun, 21 Apr 2024 11:55:10 +0100 Subject: [PATCH 11/17] Remove debug code. --- test/Stateless.Tests/AsyncActionsFixture.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/Stateless.Tests/AsyncActionsFixture.cs b/test/Stateless.Tests/AsyncActionsFixture.cs index 51f8398e..f0f8d46a 100644 --- a/test/Stateless.Tests/AsyncActionsFixture.cs +++ b/test/Stateless.Tests/AsyncActionsFixture.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Threading; using System.Threading.Tasks; using Xunit; using System.Globalization; @@ -562,7 +561,6 @@ public async Task FireAsyncTriggerWithParametersArray() }); await sm.FireAsync(Trigger.X, 42, "Stateless", true, 123.45, Trigger.Y); - Console.WriteLine(Thread.CurrentThread.CurrentCulture); Assert.Equal(expectedParam, actualParam); } From 81d3b072b14c1bc0beaeb168128fc546aa3ba1ab Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Mon, 22 Apr 2024 21:19:21 +0100 Subject: [PATCH 12/17] #565 Permit state reentry from dynamic transitions with FireAsync; retrofit #544 onto FireAsync. --- src/Stateless/StateMachine.Async.cs | 13 +- src/Stateless/StateMachine.cs | 60 +++---- test/Stateless.Tests/AsyncActionsFixture.cs | 22 +++ .../DynamicTriggerBehaviourAsyncFixture.cs | 168 ++++++++++++++++++ .../DynamicTriggerBehaviourFixture.cs | 2 +- 5 files changed, 233 insertions(+), 32 deletions(-) create mode 100644 test/Stateless.Tests/DynamicTriggerBehaviourAsyncFixture.cs diff --git a/src/Stateless/StateMachine.Async.cs b/src/Stateless/StateMachine.Async.cs index 846842a5..919dedda 100644 --- a/src/Stateless/StateMachine.Async.cs +++ b/src/Stateless/StateMachine.Async.cs @@ -220,8 +220,19 @@ async Task InternalFireOneAsync(TTrigger trigger, params object[] args) break; } case DynamicTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out var destination): - case TransitioningTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out destination): { + // 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 TransitioningTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out var destination): + { + // If a trigger was found on a superstate that would cause unintended reentry, don't trigger. + if (source.Equals(destination)) + break; + // Handle transition, and set new state var transition = new Transition(source, destination, trigger, args); await HandleTransitioningTriggerAsync(args, representativeState, transition); diff --git a/src/Stateless/StateMachine.cs b/src/Stateless/StateMachine.cs index 63c63636..121d6ede 100644 --- a/src/Stateless/StateMachine.cs +++ b/src/Stateless/StateMachine.cs @@ -47,7 +47,7 @@ private class QueuedTrigger /// /// A function that will be called to read the current state value. /// An action that will be called to write new state values. - public StateMachine(Func stateAccessor, Action stateMutator) :this(stateAccessor, stateMutator, FiringMode.Queued) + public StateMachine(Func stateAccessor, Action stateMutator) : this(stateAccessor, stateMutator, FiringMode.Queued) { } @@ -414,39 +414,39 @@ void InternalFireOne(TTrigger trigger, params object[] args) // Handle special case, re-entry in superstate // Check if it is an internal transition, or a transition from one state to another. case ReentryTriggerBehaviour handler: - { - // Handle transition, and set new state - var transition = new Transition(source, handler.Destination, trigger, args); - HandleReentryTrigger(args, representativeState, transition); - break; - } + { + // Handle transition, and set new state + var transition = new Transition(source, handler.Destination, trigger, args); + HandleReentryTrigger(args, representativeState, transition); + break; + } case DynamicTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out var destination): - { - // Handle transition, and set new state; reentry is permitted from dynamic trigger behaviours. - var transition = new Transition(source, destination, trigger, args); - HandleTransitioningTrigger(args, representativeState, transition); + { + // Handle transition, and set new state; reentry is permitted from dynamic trigger behaviours. + var transition = new Transition(source, destination, trigger, args); + HandleTransitioningTrigger(args, representativeState, transition); - break; - } - case TransitioningTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out var destination): - { - // If a trigger was found on a superstate that would cause unintended reentry, don't trigger. - if (source.Equals(destination)) break; + } + case TransitioningTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out var destination): + { + // If a trigger was found on a superstate that would cause unintended reentry, don't trigger. + if (source.Equals(destination)) + break; - // Handle transition, and set new state - var transition = new Transition(source, destination, trigger, args); - HandleTransitioningTrigger(args, representativeState, transition); + // Handle transition, and set new state + var transition = new Transition(source, destination, trigger, args); + HandleTransitioningTrigger(args, representativeState, transition); - break; - } + break; + } case InternalTriggerBehaviour _: - { - // Internal transitions does not update the current state, but must execute the associated action. - var transition = new Transition(source, source, trigger, args); - CurrentRepresentation.InternalAction(transition, args); - break; - } + { + // Internal transitions does not update the current state, but must execute the associated action. + var transition = new Transition(source, source, trigger, args); + CurrentRepresentation.InternalAction(transition, args); + break; + } default: throw new InvalidOperationException("State machine configuration incorrect, no handler for trigger."); } @@ -478,7 +478,7 @@ private void HandleReentryTrigger(object[] args, StateRepresentation representat State = representation.UnderlyingState; } - private void HandleTransitioningTrigger( object[] args, StateRepresentation representativeState, Transition transition) + private void HandleTransitioningTrigger(object[] args, StateRepresentation representativeState, Transition transition) { transition = representativeState.Exit(transition); @@ -499,7 +499,7 @@ private void HandleTransitioningTrigger( object[] args, StateRepresentation repr _onTransitionCompletedEvent.Invoke(new Transition(transition.Source, State, transition.Trigger, transition.Parameters)); } - private StateRepresentation EnterState(StateRepresentation representation, Transition transition, object [] args) + private StateRepresentation EnterState(StateRepresentation representation, Transition transition, object[] args) { // Enter the new state representation.Enter(transition, args); diff --git a/test/Stateless.Tests/AsyncActionsFixture.cs b/test/Stateless.Tests/AsyncActionsFixture.cs index f0f8d46a..0dd48a28 100644 --- a/test/Stateless.Tests/AsyncActionsFixture.cs +++ b/test/Stateless.Tests/AsyncActionsFixture.cs @@ -589,6 +589,28 @@ public async Task FireAsync_TriggerWithMoreThanThreeParameters() Assert.Equal(expectedParam, actualParam); } + + [Fact] + public async Task WhenInSubstate_TriggerSuperStateTwiceToSameSubstate_DoesNotReenterSubstate_Async() + { + var sm = new StateMachine(State.A); + var eCount = 0; + + sm.Configure(State.B) + .OnEntry(() => { eCount++; }) + .SubstateOf(State.C); + + sm.Configure(State.A) + .SubstateOf(State.C); + + sm.Configure(State.C) + .Permit(Trigger.X, State.B); + + await sm.FireAsync(Trigger.X); + await sm.FireAsync(Trigger.X); + + Assert.Equal(1, eCount); + } } } diff --git a/test/Stateless.Tests/DynamicTriggerBehaviourAsyncFixture.cs b/test/Stateless.Tests/DynamicTriggerBehaviourAsyncFixture.cs new file mode 100644 index 00000000..b2d09fee --- /dev/null +++ b/test/Stateless.Tests/DynamicTriggerBehaviourAsyncFixture.cs @@ -0,0 +1,168 @@ +using System; +using System.Threading.Tasks; +using Xunit; + +namespace Stateless.Tests +{ + public class DynamicTriggerBehaviourAsyncFixture + { + [Fact] + public async Task PermitDynamic_Selects_Expected_State_Async() + { + var sm = new StateMachine(State.A); + sm.Configure(State.A) + .PermitDynamic(Trigger.X, () => 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) + .PermitDynamic(trigger, i => 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) + .PermitDynamic(Trigger.X, () => 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) + .PermitDynamic(Trigger.X, () => 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) + .PermitDynamicIf(trigger, (i) => 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).PermitDynamicIf( + trigger, + (i, j) => 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).PermitDynamicIf( + trigger, + (i, j, k) => 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) + .PermitDynamicIf(trigger, (i) => i > 0 ? State.C : State.B, (i) => i == 2 ? true : false); + + 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).PermitDynamicIf( + trigger, + (i, j) => 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).PermitDynamicIf(trigger, + (i, j, k) => 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) + .PermitDynamicIf(Trigger.X, () => 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/DynamicTriggerBehaviourFixture.cs b/test/Stateless.Tests/DynamicTriggerBehaviourFixture.cs index 9fbf5d39..893a3aec 100644 --- a/test/Stateless.Tests/DynamicTriggerBehaviourFixture.cs +++ b/test/Stateless.Tests/DynamicTriggerBehaviourFixture.cs @@ -3,7 +3,7 @@ namespace Stateless.Tests { - public class DynamicTriggerBehaviour + public class DynamicTriggerBehaviourFixture { [Fact] public void PermitDynamic_Selects_Expected_State() From 9da05491f41fb18c1b8da169030f1505a1924f2b Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Mon, 22 Apr 2024 21:45:44 +0100 Subject: [PATCH 13/17] Add test coverage for incoming commit --- test/Stateless.Tests/AsyncActionsFixture.cs | 1 - test/Stateless.Tests/ReflectionFixture.cs | 8 ++++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/test/Stateless.Tests/AsyncActionsFixture.cs b/test/Stateless.Tests/AsyncActionsFixture.cs index 0dd48a28..aed8ba58 100644 --- a/test/Stateless.Tests/AsyncActionsFixture.cs +++ b/test/Stateless.Tests/AsyncActionsFixture.cs @@ -6,7 +6,6 @@ using System.Linq; using System.Threading.Tasks; using Xunit; -using System.Globalization; namespace Stateless.Tests { diff --git a/test/Stateless.Tests/ReflectionFixture.cs b/test/Stateless.Tests/ReflectionFixture.cs index aebe54e9..eff37206 100644 --- a/test/Stateless.Tests/ReflectionFixture.cs +++ b/test/Stateless.Tests/ReflectionFixture.cs @@ -892,6 +892,14 @@ StateConfiguration InternalPermit(TTrigger trigger, TState destinationState, str StateConfiguration InternalPermitDynamic(TTrigger trigger, Func destinationStateSelector, string guardDescription) */ } + + [Fact] + public void InvocationInfo_Description_Property_When_Method_Name_Is_Null_Returns_String_Literal_Null() + { + var invocationInfo = new InvocationInfo(null, null, InvocationInfo.Timing.Synchronous); + + Assert.Equal("", invocationInfo.Description); + } } } From 0f2396e48c661a9e95bbf4be2a3cc697a67a46a3 Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Sun, 12 May 2024 11:01:59 +0100 Subject: [PATCH 14/17] #550 Add overloads to CanFire to support trigger parameters --- src/Stateless/StateMachine.cs | 118 ++++++++++++- test/Stateless.Tests/StateMachineFixture.cs | 183 +++++++++++++++++++- 2 files changed, 298 insertions(+), 3 deletions(-) diff --git a/src/Stateless/StateMachine.cs b/src/Stateless/StateMachine.cs index 121d6ede..bb9fa877 100644 --- a/src/Stateless/StateMachine.cs +++ b/src/Stateless/StateMachine.cs @@ -570,6 +570,12 @@ public bool IsInState(TState state) /// Returns true if can be fired /// in the current state. /// + /// + /// When the trigger is configured with parameters, the default value of each of the trigger parameter's types will be used + /// to evaluate whether it can fire, which may not be the desired behavior; to check if a trigger can be fired with specific arguments, + /// use the overload of CanFire<TArg1[, TArg2[ ,TArg3]]>(TriggerWithParameters<TArg1[, TArg2[ ,TArg3]]>, ...) that + /// matches the type arguments of your trigger. + /// /// Trigger to test. /// True if the trigger can be fired, false otherwise. public bool CanFire(TTrigger trigger) @@ -579,8 +585,64 @@ public bool CanFire(TTrigger trigger) /// /// Returns true if can be fired - /// in the current state. + /// in the current state using the supplied trigger argument. + /// + /// Type of the first trigger argument. + /// Trigger to test. + /// The first argument. + /// True if the trigger can be fired, false otherwise. + public bool CanFire(TriggerWithParameters trigger, TArg0 arg0) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + + return CurrentRepresentation.CanHandle(trigger.Trigger, arg0); + } + + /// + /// Returns true if can be fired + /// in the current state using the supplied trigger arguments. + /// + /// Type of the first trigger argument. + /// Type of the second trigger argument. + /// Trigger to test. + /// The first argument. + /// The second argument. + /// True if the trigger can be fired, false otherwise. + public bool CanFire(TriggerWithParameters trigger, TArg0 arg0, TArg1 arg1) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + + return CurrentRepresentation.CanHandle(trigger.Trigger, arg0, arg1); + } + + /// + /// Returns true if can be fired + /// in the current state using the supplied trigger arguments. + /// + /// Type of the first trigger argument. + /// Type of the second trigger argument. + /// Type of the third trigger argument. + /// Trigger to test. + /// The first argument. + /// The second argument. + /// The third argument. + /// True if the trigger can be fired, false otherwise. + public bool CanFire(TriggerWithParameters trigger, TArg0 arg0, TArg1 arg1, TArg2 arg2) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + + return CurrentRepresentation.CanHandle(trigger.Trigger, arg0, arg1, arg2); + } + + /// + /// Returns true if can be fired in the current state. /// + /// + /// When the trigger is configured with parameters, the default value of each of the trigger parameter's types will be used + /// to evaluate whether it can fire, which may not be the desired behavior; to check if a trigger can be fired with specific arguments, + /// use the overload of CanFire<TArg1[, TArg2[ ,TArg3]]>(TriggerWithParameters<TArg1[, TArg2[ ,TArg3]]>, ...) that + /// matches the type arguments of your trigger. + /// /// Trigger to test. /// Guard descriptions of unmet guards. If given trigger is not configured for current state, this will be null. /// True if the trigger can be fired, false otherwise. @@ -589,6 +651,60 @@ public bool CanFire(TTrigger trigger, out ICollection unmetGuards) return CurrentRepresentation.CanHandle(trigger, new object[] { }, out unmetGuards); } + /// + /// Returns true if can be fired + /// in the current state using the supplied trigger argument. + /// + /// Type of the first trigger argument. + /// Trigger to test. + /// The first argument. + /// Guard descriptions of unmet guards. If given trigger is not configured for current state, this will be null. + /// True if the trigger can be fired, false otherwise. + public bool CanFire(TriggerWithParameters trigger, TArg0 arg0, out ICollection unmetGuards) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + + return CurrentRepresentation.CanHandle(trigger.Trigger, new object[] { arg0 }, out unmetGuards); + } + + /// + /// Returns true if can be fired + /// in the current state using the supplied trigger arguments. + /// + /// Type of the first trigger argument. + /// Type of the second trigger argument. + /// Trigger to test. + /// The first argument. + /// The second argument. + /// Guard descriptions of unmet guards. If given trigger is not configured for current state, this will be null. + /// True if the trigger can be fired, false otherwise. + public bool CanFire(TriggerWithParameters trigger, TArg0 arg0, TArg1 arg1, out ICollection unmetGuards) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + + return CurrentRepresentation.CanHandle(trigger.Trigger, new object[] { arg0, arg1 }, out unmetGuards); + } + + /// + /// Returns true if can be fired + /// in the current state using the supplied trigger arguments. + /// + /// Type of the first trigger argument. + /// Type of the second trigger argument. + /// Type of the third trigger argument. + /// Trigger to test. + /// The first argument. + /// The second argument. + /// The third argument. + /// Guard descriptions of unmet guards. If given trigger is not configured for current state, this will be null. + /// True if the trigger can be fired, false otherwise. + public bool CanFire(TriggerWithParameters trigger, TArg0 arg0, TArg1 arg1, TArg2 arg2, out ICollection unmetGuards) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + + return CurrentRepresentation.CanHandle(trigger.Trigger, new object[] { arg0, arg1, arg2 }, out unmetGuards); + } + /// /// A human-readable representation of the state machine. /// diff --git a/test/Stateless.Tests/StateMachineFixture.cs b/test/Stateless.Tests/StateMachineFixture.cs index f89b8560..91cb42ac 100644 --- a/test/Stateless.Tests/StateMachineFixture.cs +++ b/test/Stateless.Tests/StateMachineFixture.cs @@ -115,7 +115,7 @@ public void WhenInSubstate_TriggerSuperStateTwiceToSameSubstate_DoesNotReenterSu var eCount = 0; sm.Configure(State.B) - .OnEntry(() => { eCount++;}) + .OnEntry(() => { eCount++; }) .SubstateOf(State.C); sm.Configure(State.A) @@ -721,6 +721,185 @@ public void ExceptionWhenPermitIfHasMultipleNonExclusiveGuards() Assert.Throws(() => sm.Fire(x, 2)); } + [Fact] + public void CanFire_When_Transition_Is_Conditional_On_Default_Value_And_Trigger_Parameters_Are_Omitted_Returns_True() + { + // This test verifies behavior in CanFire that may be considered a bug. + // When a PermitIf transition is configured with a parameterized trigger and the trigger is fired without parameters, + // CanFire will test the transition with the default values of the omitted trigger parameters' types. + var sm = new StateMachine(State.A); + var valueTrigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitIf(valueTrigger, State.B, i => i == default); + + Assert.True(sm.CanFire(Trigger.X)); + } + + [Fact] + public void CanFire_When_Transition_Is_Conditional_On_Non_Default_Value_And_Trigger_Parameters_Are_Omitted_Returns_False() + { + // This test verifies behavior in CanFire that may be considered a bug. + // When a PermitIf transition is configured with a parameterized trigger and the trigger is fired without parameters, + // CanFire will test the transition with the default values of the omitted trigger parameters' types. + var sm = new StateMachine(State.A); + var valueTrigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitIf(valueTrigger, State.B, i => i == 1); + + Assert.False(sm.CanFire(Trigger.X)); + } + + [Fact] + public void CanFire_When_Transition_Is_Conditional_And_One_Trigger_Parameter_Is_Used_And_Condition_Is_Met_Returns_True() + { + var sm = new StateMachine(State.A); + var valueTrigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitIf(valueTrigger, State.B, i => i == 1); + + Assert.True(sm.CanFire(valueTrigger, 1)); + } + + [Fact] + public void CanFire_When_Transition_Is_Conditional_And_One_Trigger_Parameter_Is_Used_And_Condition_Is_NotMet_Returns_False() + { + var sm = new StateMachine(State.A); + var valueTrigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitIf(valueTrigger, State.B, i => i != 1); + + Assert.False(sm.CanFire(valueTrigger, 1)); + } + + [Fact] + public void CanFire_When_Transition_Is_Conditional_And_Two_Trigger_Parameters_Are_Used_And_Condition_Is_Met_Returns_True() + { + var sm = new StateMachine(State.A); + var valueTrigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitIf(valueTrigger, State.B, (i, s) => i == 1 && s.Equals("X", StringComparison.Ordinal)); + + Assert.True(sm.CanFire(valueTrigger, 1, "X")); + } + + [Fact] + public void CanFire_When_Transition_Is_Conditional_And_Two_Trigger_Parameters_Are_Used_And_Condition_Is_Not_Met_Returns_False() + { + var sm = new StateMachine(State.A); + var valueTrigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitIf(valueTrigger, State.B, (i, s) => i != 1 && s.Equals("Y", StringComparison.Ordinal)); + + Assert.False(sm.CanFire(valueTrigger, 1, "X")); + } + + [Fact] + public void CanFire_When_Transition_Is_Conditional_And_Three_Trigger_Parameters_Are_Used_And_Condition_Is_Met_Returns_True() + { + var sm = new StateMachine(State.A); + var valueTrigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitIf(valueTrigger, State.B, (i, s, b) => i == 1 && s.Equals("X", StringComparison.Ordinal) && b); + + Assert.True(sm.CanFire(valueTrigger, 1, "X", true)); + } + + [Fact] + public void CanFire_When_Transition_Is_Conditional_And_Three_Trigger_Parameters_Are_Used_And_Condition_Is_Not_Met_Returns_False() + { + var sm = new StateMachine(State.A); + var valueTrigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A).PermitIf(valueTrigger, State.B, (i, s, b) => i != 1 && s.Equals("Y", StringComparison.Ordinal) && !b); + + Assert.False(sm.CanFire(valueTrigger, 1, "X", true)); + } + + [Fact] + public void CanFire_When_Transition_Is_Contidional_And_Has_One_Trigger_Parameter_And_A_Guard_Condition_Is_Met_Returns_Empty_Collection() + { + var sm = new StateMachine(State.A); + var valueTrigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitIf(valueTrigger, State.B, i => i == 1, "i equal to 1") + .PermitIf(valueTrigger, State.B, i => i == 2, "i equal to 2"); + + bool result = sm.CanFire(valueTrigger, 1, out ICollection unmetGuards); + + Assert.True(result); + Assert.False(unmetGuards.Any()); + } + + [Fact] + public void CanFire_When_Transition_Is_Contidional_And_Has_One_Trigger_Parameter_And_No_Guard_Conditions_Are_Met_Returns_Guard_Conditions() + { + var sm = new StateMachine(State.A); + var valueTrigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitIf(valueTrigger, State.B, i => i == 1, "i equal to 1") + .PermitIf(valueTrigger, State.B, i => i == 2, "i equal to 2"); + + bool result = sm.CanFire(valueTrigger, 3, out ICollection unmetGuards); + + Assert.Collection(unmetGuards, + item => Assert.Equal("i equal to 1", item), + item => Assert.Equal("i equal to 2", item)); + } + + [Fact] + public void CanFire_When_Transition_Is_Contidional_And_Has_Two_Trigger_Parameters_And_A_Guard_Condition_Is_Met_Returns_Empty_Collection() + { + var sm = new StateMachine(State.A); + var valueTrigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitIf(valueTrigger, State.B, (i, s) => i == 1 && s == "X", "i equal to 1 and s equal to 'X'") + .PermitIf(valueTrigger, State.B, (i, s) => i == 2 && s == "X", "i equal to 2 and s equal to 'Y'"); + + bool result = sm.CanFire(valueTrigger, 1, "X", out ICollection unmetGuards); + + Assert.True(result); + Assert.False(unmetGuards.Any()); + } + + [Fact] + public void CanFire_When_Transition_Is_Contidional_And_Has_Two_Trigger_Parameters_And_No_Guard_Conditions_Are_Met_Returns_Guard_Conditions() + { + var sm = new StateMachine(State.A); + var valueTrigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitIf(valueTrigger, State.B, (i, s) => i == 1 && s == "X", "i equal to 1 and s equal to 'X'") + .PermitIf(valueTrigger, State.B, (i, s) => i == 2 && s == "X", "i equal to 2 and s equal to 'Y'"); + + bool result = sm.CanFire(valueTrigger, 3, "Z", out ICollection unmetGuards); + + Assert.Collection(unmetGuards, + item => Assert.Equal("i equal to 1 and s equal to 'X'", item), + item => Assert.Equal("i equal to 2 and s equal to 'Y'", item)); + } + + [Fact] + public void CanFire_When_Transition_Is_Contidional_And_Has_Three_Trigger_Parameters_And_A_Guard_Condition_Is_Met_Returns_Empty_Collection() + { + var sm = new StateMachine(State.A); + var valueTrigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitIf(valueTrigger, State.B, (i, s, b) => i == 1 && s == "X", "i equal to 1 and s equal to 'X' and boolean is true") + .PermitIf(valueTrigger, State.B, (i, s, b) => i == 2 && s == "X", "i equal to 2 and s equal to 'Y' and boolean is true"); + + bool result = sm.CanFire(valueTrigger, 1, "X", true, out ICollection unmetGuards); + + Assert.True(result); + Assert.False(unmetGuards.Any()); + } + + [Fact] + public void CanFire_When_Transition_Is_Contidional_And_Has_Three_Trigger_Parameters_And_No_Guard_Conditions_Are_Met_Returns_Guard_Conditions() + { + var sm = new StateMachine(State.A); + var valueTrigger = sm.SetTriggerParameters(Trigger.X); + sm.Configure(State.A) + .PermitIf(valueTrigger, State.B, (i, s, b) => i == 1 && s == "X", "i equal to 1 and s equal to 'X' and boolean is true") + .PermitIf(valueTrigger, State.B, (i, s, b) => i == 2 && s == "X", "i equal to 2 and s equal to 'Y' and boolean is true"); + + bool result = sm.CanFire(valueTrigger, 3, "Z", false, out ICollection unmetGuards); + + Assert.Collection(unmetGuards, + item => Assert.Equal("i equal to 1 and s equal to 'X' and boolean is true", item), + item => Assert.Equal("i equal to 2 and s equal to 'Y' and boolean is true", item)); + } + [Fact] public void TransitionWhenPermitDyanmicIfHasMultipleExclusiveGuards() { @@ -1048,7 +1227,7 @@ public void CanFire_GetUnmetGuardDescriptionsIfGuardFails() const string guardDescription = "Guard failed"; var sm = new StateMachine(State.A); sm.Configure(State.A) - .PermitIf(Trigger.X, State.B, ()=> false, guardDescription); + .PermitIf(Trigger.X, State.B, () => false, guardDescription); bool result = sm.CanFire(Trigger.X, out ICollection unmetGuards); From 4a98da1ad7e90da50f82d8735affa478cb2a86a8 Mon Sep 17 00:00:00 2001 From: HenningTorsteinsenBouvet Date: Tue, 21 May 2024 07:35:57 +0200 Subject: [PATCH 15/17] Remove getDestination, and use Destination property instead. Only dynamic transition needs a GetDestination, so it still has it. Updated tests according to changes. --- src/Stateless/DynamicTriggerBehaviour.cs | 3 +-- src/Stateless/IgnoredTriggerBehaviour.cs | 6 ------ src/Stateless/InternalTriggerBehaviour.cs | 6 ------ src/Stateless/ReentryTriggerBehaviour.cs | 7 ------- src/Stateless/StateMachine.Async.cs | 9 +++++---- src/Stateless/StateMachine.cs | 13 ++++++++----- src/Stateless/TransitioningTriggerBehaviour.cs | 6 ------ src/Stateless/TriggerBehaviour.cs | 4 +--- .../IgnoredTriggerBehaviourFixture.cs | 13 +++++++++---- .../SynchronizationContextFixture.cs | 5 ++--- .../TransitioningTriggerBehaviourFixture.cs | 3 +-- 11 files changed, 27 insertions(+), 48 deletions(-) diff --git a/src/Stateless/DynamicTriggerBehaviour.cs b/src/Stateless/DynamicTriggerBehaviour.cs index 99e550d4..ed694f9e 100644 --- a/src/Stateless/DynamicTriggerBehaviour.cs +++ b/src/Stateless/DynamicTriggerBehaviour.cs @@ -17,10 +17,9 @@ public DynamicTriggerBehaviour(TTrigger trigger, Func destinat TransitionInfo = info ?? throw new ArgumentNullException(nameof(info)); } - public override bool ResultsInTransitionFrom(TState source, object[] args, out TState destination) + public void GetDestinationState(TState source, object[] args, out TState destination) { destination = _destination(args); - return true; } } } diff --git a/src/Stateless/IgnoredTriggerBehaviour.cs b/src/Stateless/IgnoredTriggerBehaviour.cs index 753dc819..cb396f1e 100644 --- a/src/Stateless/IgnoredTriggerBehaviour.cs +++ b/src/Stateless/IgnoredTriggerBehaviour.cs @@ -8,12 +8,6 @@ public IgnoredTriggerBehaviour(TTrigger trigger, TransitionGuard transitionGuard : base(trigger, transitionGuard) { } - - public override bool ResultsInTransitionFrom(TState source, object[] args, out TState destination) - { - destination = default(TState); - return false; - } } } } diff --git a/src/Stateless/InternalTriggerBehaviour.cs b/src/Stateless/InternalTriggerBehaviour.cs index 3966a945..328ef769 100644 --- a/src/Stateless/InternalTriggerBehaviour.cs +++ b/src/Stateless/InternalTriggerBehaviour.cs @@ -14,12 +14,6 @@ protected InternalTriggerBehaviour(TTrigger trigger, TransitionGuard guard) : ba public abstract void Execute(Transition transition, object[] args); public abstract Task ExecuteAsync(Transition transition, object[] args); - public override bool ResultsInTransitionFrom(TState source, object[] args, out TState destination) - { - destination = source; - return false; - } - public class Sync : InternalTriggerBehaviour { public Action InternalAction { get; } diff --git a/src/Stateless/ReentryTriggerBehaviour.cs b/src/Stateless/ReentryTriggerBehaviour.cs index 29ecc344..95df24e9 100644 --- a/src/Stateless/ReentryTriggerBehaviour.cs +++ b/src/Stateless/ReentryTriggerBehaviour.cs @@ -14,13 +14,6 @@ public ReentryTriggerBehaviour(TTrigger trigger, TState destination, TransitionG { _destination = destination; } - - public override bool ResultsInTransitionFrom(TState source, object[] args, out TState destination) - { - destination = _destination; - return true; - } } - } } diff --git a/src/Stateless/StateMachine.Async.cs b/src/Stateless/StateMachine.Async.cs index 919dedda..f69d0065 100644 --- a/src/Stateless/StateMachine.Async.cs +++ b/src/Stateless/StateMachine.Async.cs @@ -219,22 +219,23 @@ async Task InternalFireOneAsync(TTrigger trigger, params object[] args) await HandleReentryTriggerAsync(args, representativeState, transition); break; } - case DynamicTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out var destination): + case DynamicTriggerBehaviour handler: { + handler.GetDestinationState(source, args, out var destination); // 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 TransitioningTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out var destination): + case TransitioningTriggerBehaviour handler: { // If a trigger was found on a superstate that would cause unintended reentry, don't trigger. - if (source.Equals(destination)) + if (source.Equals(handler.Destination)) break; // Handle transition, and set new state - var transition = new Transition(source, destination, trigger, args); + var transition = new Transition(source, handler.Destination, trigger, args); await HandleTransitioningTriggerAsync(args, representativeState, transition); break; diff --git a/src/Stateless/StateMachine.cs b/src/Stateless/StateMachine.cs index bb9fa877..a28c5f48 100644 --- a/src/Stateless/StateMachine.cs +++ b/src/Stateless/StateMachine.cs @@ -390,11 +390,13 @@ private void InternalFireQueued(TTrigger trigger, params object[] args) /// /// /// - void InternalFireOne(TTrigger trigger, params object[] args) + private void InternalFireOne(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)) + { configuration.ValidateParameters(args); + } var source = State; var representativeState = GetRepresentation(source); @@ -420,22 +422,23 @@ void InternalFireOne(TTrigger trigger, params object[] args) HandleReentryTrigger(args, representativeState, transition); break; } - case DynamicTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out var destination): + case DynamicTriggerBehaviour handler: { + handler.GetDestinationState(source, args, out var destination); // Handle transition, and set new state; reentry is permitted from dynamic trigger behaviours. var transition = new Transition(source, destination, trigger, args); HandleTransitioningTrigger(args, representativeState, transition); break; } - case TransitioningTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out var destination): + case TransitioningTriggerBehaviour handler: { // If a trigger was found on a superstate that would cause unintended reentry, don't trigger. - if (source.Equals(destination)) + if (source.Equals(handler.Destination)) break; // Handle transition, and set new state - var transition = new Transition(source, destination, trigger, args); + var transition = new Transition(source, handler.Destination, trigger, args); HandleTransitioningTrigger(args, representativeState, transition); break; diff --git a/src/Stateless/TransitioningTriggerBehaviour.cs b/src/Stateless/TransitioningTriggerBehaviour.cs index 269b95e1..417a8cf2 100644 --- a/src/Stateless/TransitioningTriggerBehaviour.cs +++ b/src/Stateless/TransitioningTriggerBehaviour.cs @@ -12,12 +12,6 @@ public TransitioningTriggerBehaviour(TTrigger trigger, TState destination, Trans { Destination = destination; } - - public override bool ResultsInTransitionFrom(TState source, object[] args, out TState destination) - { - destination = Destination; - return true; - } } } } diff --git a/src/Stateless/TriggerBehaviour.cs b/src/Stateless/TriggerBehaviour.cs index 331ec520..f878e6e1 100644 --- a/src/Stateless/TriggerBehaviour.cs +++ b/src/Stateless/TriggerBehaviour.cs @@ -37,7 +37,7 @@ protected TriggerBehaviour(TTrigger trigger, TransitionGuard guard) internal ICollection> Guards =>_guard.Guards; /// - /// GuardConditionsMet is true if all of the guard functions return true + /// GuardConditionsMet is true if all the guard functions return true /// or if there are no guard functions /// public bool GuardConditionsMet(params object[] args) => _guard.GuardConditionsMet(args); @@ -47,8 +47,6 @@ protected TriggerBehaviour(TTrigger trigger, TransitionGuard guard) /// whose guard function returns false /// public ICollection UnmetGuardConditions(object[] args) => _guard.UnmetGuardConditions(args); - - public abstract bool ResultsInTransitionFrom(TState source, object[] args, out TState destination); } } } diff --git a/test/Stateless.Tests/IgnoredTriggerBehaviourFixture.cs b/test/Stateless.Tests/IgnoredTriggerBehaviourFixture.cs index 63d120ab..de516805 100644 --- a/test/Stateless.Tests/IgnoredTriggerBehaviourFixture.cs +++ b/test/Stateless.Tests/IgnoredTriggerBehaviourFixture.cs @@ -8,8 +8,12 @@ public class IgnoredTriggerBehaviourFixture [Fact] public void StateRemainsUnchanged() { - var ignored = new StateMachine.IgnoredTriggerBehaviour(Trigger.X, null); - Assert.False(ignored.ResultsInTransitionFrom(State.B, new object[0], out _)); + var sm = new StateMachine(State.A); + sm.Configure(State.A).Ignore(Trigger.X); + + sm.Fire(Trigger.X); + + Assert.Equal(State.A, sm.State); } [Fact] @@ -21,7 +25,7 @@ public void ExposesCorrectUnderlyingTrigger() Assert.Equal(Trigger.X, ignored.Trigger); } - protected bool False(params object[] args) + private bool False(params object[] args) { return false; } @@ -35,7 +39,7 @@ public void WhenGuardConditionFalse_IsGuardConditionMetIsFalse() Assert.False(ignored.GuardConditionsMet()); } - protected bool True(params object[] args) + private bool True(params object[] args) { return true; } @@ -48,6 +52,7 @@ public void WhenGuardConditionTrue_IsGuardConditionMetIsTrue() Assert.True(ignored.GuardConditionsMet()); } + [Fact] public void IgnoredTriggerMustBeIgnoredSync() { diff --git a/test/Stateless.Tests/SynchronizationContextFixture.cs b/test/Stateless.Tests/SynchronizationContextFixture.cs index 26eaccf6..c4f8cacd 100644 --- a/test/Stateless.Tests/SynchronizationContextFixture.cs +++ b/test/Stateless.Tests/SynchronizationContextFixture.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -90,7 +89,7 @@ public async Task Activation_of_state_with_superstate_should_retain_SyncContext( sm.Configure(State.A) .OnActivateAsync(CaptureThenLoseSyncContext) .SubstateOf(State.B); - ; + sm.Configure(State.B) .OnActivateAsync(CaptureThenLoseSyncContext); @@ -110,7 +109,7 @@ public async Task Deactivation_of_state_with_superstate_should_retain_SyncContex sm.Configure(State.A) .OnDeactivateAsync(CaptureThenLoseSyncContext) .SubstateOf(State.B); - ; + sm.Configure(State.B) .OnDeactivateAsync(CaptureThenLoseSyncContext); diff --git a/test/Stateless.Tests/TransitioningTriggerBehaviourFixture.cs b/test/Stateless.Tests/TransitioningTriggerBehaviourFixture.cs index 6c739560..486113ff 100644 --- a/test/Stateless.Tests/TransitioningTriggerBehaviourFixture.cs +++ b/test/Stateless.Tests/TransitioningTriggerBehaviourFixture.cs @@ -8,8 +8,7 @@ public class TransitioningTriggerBehaviourFixture public void TransitionsToDestinationState() { var transitioning = new StateMachine.TransitioningTriggerBehaviour(Trigger.X, State.C, null); - Assert.True(transitioning.ResultsInTransitionFrom(State.B, new object[0], out State destination)); - Assert.Equal(State.C, destination); + Assert.Equal(State.C, transitioning.Destination); } } } From e727fac6bade44a32eccd197cc7ec2665ba14293 Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Tue, 21 May 2024 09:52:46 +0100 Subject: [PATCH 16/17] Prepare release 5.16.0 --- .../workflows/BuildAndTestOnPullRequests.yml | 2 +- CHANGELOG.md | 17 +++++++++++++++++ src/Stateless/Stateless.csproj | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/BuildAndTestOnPullRequests.yml b/.github/workflows/BuildAndTestOnPullRequests.yml index 5f843ed1..b995b1dc 100644 --- a/.github/workflows/BuildAndTestOnPullRequests.yml +++ b/.github/workflows/BuildAndTestOnPullRequests.yml @@ -10,7 +10,7 @@ jobs: Build_Stateless_solution: runs-on: windows-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install dependencies run: dotnet restore diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f0d51f..a7bd4f44 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.16.0 - 2024.05.21 +### Changed + - Permit state reentry from dynamic transitions [#565] + - This is a change in behavior from v5.15.0 (see [#544]); this version restores the previous behavior for `PermitDynamic` that allows reentry; + if reentry is not the desired behavior, consider using a guard condition with `PermitDynamicIf`. + - Remove getDestination, and use Destination property instead (internal refactor) [#575] +### Added + - Add overloads to `FireAsync` to support parameterized trigger arguments [#570] + - Add overloads to `CanFire` to support parameterized trigger arguments [#574] +### Fixed + - Prevent `NullReferenceException` in the `InvocationInfo` class [#566] + ## 5.15.0 - 2023.12.29 ### Changed - Updated net6.0 build target to net8.0 [#551] @@ -210,6 +222,11 @@ Version 5.10.0 is now listed as the newest, since it has the highest version num ### Removed ### Fixed +[#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 +[#566]: https://github.com/dotnet-state-machine/stateless/pull/566 +[#565]: https://github.com/dotnet-state-machine/stateless/issues/565 [#551]: https://github.com/dotnet-state-machine/stateless/pull/551 [#557]: https://github.com/dotnet-state-machine/stateless/issues/557 [#553]: https://github.com/dotnet-state-machine/stateless/issues/553 diff --git a/src/Stateless/Stateless.csproj b/src/Stateless/Stateless.csproj index e9cc3285..a3d0771c 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.15.0 + 5.16.0 Stateless Contributors true true From 55ceac6bbf1ff022a87808634dc3de27c192c654 Mon Sep 17 00:00:00 2001 From: Mike Clift Date: Wed, 22 May 2024 13:26:35 +0100 Subject: [PATCH 17/17] Bump release date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7bd4f44..6f103ce9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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.16.0 - 2024.05.21 +## 5.16.0 - 2024.05.24 ### Changed - Permit state reentry from dynamic transitions [#565] - This is a change in behavior from v5.15.0 (see [#544]); this version restores the previous behavior for `PermitDynamic` that allows reentry;