diff --git a/source/Lite.StateMachine.Tests/StateTests/BasicStateTests.cs b/source/Lite.StateMachine.Tests/StateTests/BasicStateTests.cs index 94865ea..f3979c8 100644 --- a/source/Lite.StateMachine.Tests/StateTests/BasicStateTests.cs +++ b/source/Lite.StateMachine.Tests/StateTests/BasicStateTests.cs @@ -12,10 +12,6 @@ namespace Lite.StateMachine.Tests.StateTests; [TestClass] public class BasicStateTests { - public const string ParameterCounter = "Counter"; - public const string ParameterKeyTest = "TestKey"; - public const string TestValue = "success"; - public TestContext TestContext { get; set; } /// Standard synchronous state registration exiting to completion. @@ -23,8 +19,11 @@ public class BasicStateTests public void Basic_RegisterState_Executes123_SuccessTest() { // Assemble - var counter = 0; - var ctxProperties = new PropertyBag() { { ParameterCounter, counter } }; + var ctxProperties = new PropertyBag() + { + { ParameterType.Counter, 0 }, + { ParameterType.TestExecutionOrder, true }, + }; var machine = new StateMachine(); machine.RegisterState(BasicStateId.State1, BasicStateId.State2); @@ -41,7 +40,7 @@ public void Basic_RegisterState_Executes123_SuccessTest() // Ensure all states are registered var enums = Enum.GetValues().Cast(); - Assert.AreEqual(enums.Count(), machine.States.Count()); + Assert.HasCount(enums.Count(), machine.States); Assert.IsTrue(enums.All(k => machine.States.Contains(k))); // Ensure they're registered in order @@ -54,8 +53,11 @@ public void Basic_RegisterState_Executes123_SuccessTest() public async Task Basic_RegisterState_Executes123_SuccessTestAsync() { // Assemble - var counter = 0; - var ctxProperties = new PropertyBag() { { ParameterCounter, counter } }; + var ctxProperties = new PropertyBag() + { + { ParameterType.Counter, 0 }, + { ParameterType.TestExecutionOrder, true }, + }; var machine = new StateMachine(); machine.RegisterState(BasicStateId.State1, BasicStateId.State2); @@ -71,7 +73,7 @@ public async Task Basic_RegisterState_Executes123_SuccessTestAsync() // Ensure all states are registered var enums = Enum.GetValues().Cast(); - Assert.AreEqual(enums.Count(), machine.States.Count()); + Assert.HasCount(enums.Count(), machine.States); Assert.IsTrue(enums.All(k => machine.States.Contains(k))); // Ensure they're registered in order @@ -88,7 +90,8 @@ public async Task Basic_RegisterState_Executes132_SuccessTestAsync() machine.RegisterState(BasicStateId.State2); // Act - Start your engine! - await machine.RunAsync(BasicStateId.State1); + var ctxProperties = new PropertyBag() { { ParameterType.TestExecutionOrder, false } }; + await machine.RunAsync(BasicStateId.State1, ctxProperties); // Assert Results Assert.IsNotNull(machine); @@ -96,7 +99,7 @@ public async Task Basic_RegisterState_Executes132_SuccessTestAsync() // Ensure all states are registered var enums = Enum.GetValues().Cast(); - Assert.AreEqual(enums.Count(), machine.States.Count()); + Assert.HasCount(enums.Count(), machine.States); Assert.IsTrue(enums.All(k => machine.States.Contains(k))); // Ensure they're NOT registered in order @@ -109,8 +112,11 @@ public async Task Basic_RegisterState_Executes132_SuccessTestAsync() public async Task Basic_RegisterState_Fluent_SuccessTestAsync() { // Assemble - var counter = 0; - var ctxProperties = new PropertyBag() { { ParameterCounter, counter } }; + var ctxProperties = new PropertyBag() + { + { ParameterType.Counter, 0 }, + { ParameterType.TestExecutionOrder, true }, + }; // Assemble/Act - Start your engine! var machine = await new StateMachine() @@ -125,7 +131,7 @@ public async Task Basic_RegisterState_Fluent_SuccessTestAsync() // Ensure all states are registered var enums = Enum.GetValues().Cast(); - Assert.AreEqual(enums.Count(), machine.States.Count()); + Assert.HasCount(enums.Count(), machine.States); Assert.IsTrue(enums.All(k => machine.States.Contains(k))); // Ensure they're registered in order @@ -168,7 +174,12 @@ public async Task HungState_Proceeds_DefaultStateTimeout_SuccessTestAsync() var machine = new StateMachine(); // This test will take 1 full second to complete versus - var paramStack = new PropertyBag() { { ParameterType.HungStateAvoidance, true } }; + var ctxProperties = new PropertyBag() + { + { ParameterType.TestHungStateAvoidance, true }, + { ParameterType.TestExecutionOrder, true }, + }; + machine.DefaultStateTimeoutMs = 1000; machine.RegisterState(BasicStateId.State1, BasicStateId.State2); @@ -176,7 +187,7 @@ public async Task HungState_Proceeds_DefaultStateTimeout_SuccessTestAsync() machine.RegisterState(BasicStateId.State3); // Act - Start your engine! - await machine.RunAsync(BasicStateId.State1, paramStack); + await machine.RunAsync(BasicStateId.State1, ctxProperties); // Assert Results Assert.IsNotNull(machine); @@ -184,7 +195,7 @@ public async Task HungState_Proceeds_DefaultStateTimeout_SuccessTestAsync() // Ensure all states are registered var enums = Enum.GetValues().Cast(); - Assert.AreEqual(enums.Count(), machine.States.Count()); + Assert.HasCount(enums.Count(), machine.States); Assert.IsTrue(enums.All(k => machine.States.Contains(k))); } @@ -194,8 +205,14 @@ public async Task HungState_Proceeds_DefaultStateTimeout_SuccessTestAsync() [Ignore("vNext - Currently StateMachine destroys context after run completes.")] public async Task RegisterState_ReturnsContext_SuccessTestAsync() { + const string TestValue = "success"; + // Assemble - var counter = 0; + var ctxProperties = new PropertyBag() + { + { ParameterType.Counter, 0 }, + { ParameterType.TestExecutionOrder, true }, + }; var machine = new StateMachine(); machine.RegisterState(BasicStateId.State1, BasicStateId.State2); @@ -203,7 +220,6 @@ public async Task RegisterState_ReturnsContext_SuccessTestAsync() machine.RegisterState(BasicStateId.State3); // Act - Start your engine! - var ctxProperties = new PropertyBag() { { ParameterCounter, counter } }; var task = machine.RunAsync(BasicStateId.State1, ctxProperties); await task; // Non async method: task.GetAwaiter().GetResult(); @@ -213,10 +229,10 @@ public async Task RegisterState_ReturnsContext_SuccessTestAsync() var ctxFinalParams = machine.Context.Parameters; Assert.IsNotNull(ctxFinalParams); - Assert.AreEqual(TestValue, ctxFinalParams[ParameterKeyTest]); + Assert.AreEqual(TestValue, ctxFinalParams[ParameterType.KeyTest]); // NOTE: This should be 9 because each state has 3 hooks that increment the counter // TODO (2025-12-22 DS): Fix last state not calling OnExit. - Assert.AreEqual(9, ctxFinalParams[ParameterCounter]); + Assert.AreEqual(9, ctxFinalParams[ParameterType.Counter]); } } diff --git a/source/Lite.StateMachine.Tests/StateTests/CompositeStateTest.cs b/source/Lite.StateMachine.Tests/StateTests/CompositeStateTest.cs index 8020450..1c283d6 100644 --- a/source/Lite.StateMachine.Tests/StateTests/CompositeStateTest.cs +++ b/source/Lite.StateMachine.Tests/StateTests/CompositeStateTest.cs @@ -61,7 +61,7 @@ public async Task Level1_Basic_RegisterHelpers_SuccessTestAsync() // Ensure all states are hit var enums = Enum.GetValues().Cast(); - Assert.AreEqual(enums.Count(), machine.States.Count()); + Assert.HasCount(enums.Count(), machine.States); Assert.IsTrue(enums.All(k => machine.States.Contains(k))); // Ensure they're in order @@ -89,7 +89,7 @@ public async Task Level1_Basic_RegisterState_SuccessTestAsync() // Ensure all states are hit var enums = Enum.GetValues().Cast(); - Assert.AreEqual(enums.Count(), machine.States.Count()); + Assert.HasCount(enums.Count(), machine.States); Assert.IsTrue(enums.All(k => machine.States.Contains(k))); // Ensure they're in order @@ -139,7 +139,7 @@ public async Task Level1_Fluent_RegisterHelpers_SuccessTestAsync() // Ensure all states are hit var enums = Enum.GetValues().Cast(); - Assert.AreEqual(enums.Count(), machine.States.Count()); + Assert.HasCount(enums.Count(), machine.States); Assert.IsTrue(enums.All(k => machine.States.Contains(k))); // Ensure they're in order @@ -166,7 +166,7 @@ public void Level1_Fluent_RegisterState_SuccessTest() // Ensure all states are hit var enums = Enum.GetValues().Cast(); - Assert.AreEqual(enums.Count(), machine.States.Count()); + Assert.HasCount(enums.Count(), machine.States); Assert.IsTrue(enums.All(k => machine.States.Contains(k))); // Ensure they're in order @@ -186,20 +186,10 @@ public async Task Level3_ExportUml_SuccessTestAsync() var msgService = services.GetRequiredService(); Func factory = t => ActivatorUtilities.CreateInstance(services, t); - var machine = new StateMachine(factory); - - machine - .RegisterState(CompositeL3.State1, CompositeL3.State2) - .RegisterComposite(CompositeL3.State2, initialChildStateId: CompositeL3.State2_Sub1, onSuccess: CompositeL3.State3) - .RegisterSubState(CompositeL3.State2_Sub1, parentStateId: CompositeL3.State2, onSuccess: CompositeL3.State2_Sub2) - .RegisterSubComposite(CompositeL3.State2_Sub2, parentStateId: CompositeL3.State2, initialChildStateId: CompositeL3.State2_Sub2_Sub1, onSuccess: CompositeL3.State2_Sub3) - .RegisterSubState(CompositeL3.State2_Sub2_Sub1, parentStateId: CompositeL3.State2_Sub2, onSuccess: CompositeL3.State2_Sub2_Sub2) - .RegisterSubState(CompositeL3.State2_Sub2_Sub2, parentStateId: CompositeL3.State2_Sub2, onSuccess: CompositeL3.State2_Sub2_Sub3) - .RegisterSubState(CompositeL3.State2_Sub2_Sub3, parentStateId: CompositeL3.State2_Sub2, onSuccess: null) - .RegisterSubState(CompositeL3.State2_Sub3, parentStateId: CompositeL3.State2, onSuccess: null) - .RegisterState(CompositeL3.State3, onSuccess: null); - + // Act + var machine = GenerateStateMachineL3(new StateMachine(factory)); var uml = machine.ExportUml([CompositeL3.State1], includeLegend: false); + Assert.IsNotNull(uml); Console.WriteLine(uml); } @@ -229,18 +219,7 @@ public async Task Level3_IsContextPersistent_False_SuccessTestAsync(bool context var msgService = services.GetRequiredService(); Func factory = t => ActivatorUtilities.CreateInstance(services, t); - var machine = new StateMachine(factory, null, isContextPersistent: contextIsPersistent); - - machine - .RegisterState(CompositeL3.State1, CompositeL3.State2) - .RegisterComposite(CompositeL3.State2, initialChildStateId: CompositeL3.State2_Sub1, onSuccess: CompositeL3.State3) - .RegisterSubState(CompositeL3.State2_Sub1, parentStateId: CompositeL3.State2, onSuccess: CompositeL3.State2_Sub2) - .RegisterSubComposite(CompositeL3.State2_Sub2, parentStateId: CompositeL3.State2, initialChildStateId: CompositeL3.State2_Sub2_Sub1, onSuccess: CompositeL3.State2_Sub3) - .RegisterSubState(CompositeL3.State2_Sub2_Sub1, parentStateId: CompositeL3.State2_Sub2, onSuccess: CompositeL3.State2_Sub2_Sub2) - .RegisterSubState(CompositeL3.State2_Sub2_Sub2, parentStateId: CompositeL3.State2_Sub2, onSuccess: CompositeL3.State2_Sub2_Sub3) - .RegisterSubState(CompositeL3.State2_Sub2_Sub3, parentStateId: CompositeL3.State2_Sub2, onSuccess: null) - .RegisterSubState(CompositeL3.State2_Sub3, parentStateId: CompositeL3.State2, onSuccess: null) - .RegisterState(CompositeL3.State3, onSuccess: null); + var machine = GenerateStateMachineL3(new StateMachine(factory, null, isContextPersistent: contextIsPersistent)); // Act await machine.RunAsync(CompositeL3.State1, cancellationToken: TestContext.CancellationToken); @@ -251,7 +230,7 @@ public async Task Level3_IsContextPersistent_False_SuccessTestAsync(bool context // Ensure all states are registered var enums = Enum.GetValues().Cast(); - Assert.AreEqual(enums.Count(), machine.States.Count()); + Assert.HasCount(enums.Count(), machine.States); Assert.IsTrue(enums.All(stateId => machine.States.Contains(stateId))); // State Transition counter (9 states, 3 transitions) @@ -287,4 +266,43 @@ public async Task Level3_IsContextPersistent_False_SuccessTestAsync(bool context Assert.AreEqual(11, msgService.Counter3); } } + + [TestMethod] + public async Task Level3_PreviousStateId_SuccessTestAsync() + { + // Assemble - Using DI for MessageService's counters + var services = new ServiceCollection() + .AddLogging(InlineTraceLogger(LogLevel.None)) + .AddSingleton() + .BuildServiceProvider(); + + var msgService = services.GetRequiredService(); + Func factory = t => ActivatorUtilities.CreateInstance(services, t); + + var ctxProperties = new PropertyBag() { { ParameterType.TestExecutionOrder, true } }; + var machine = GenerateStateMachineL3(new StateMachine(factory)); + + // Act + await machine.RunAsync(CompositeL3.State1, ctxProperties, null, TestContext.CancellationToken); + + // Assert + Assert.IsNotNull(machine); + Assert.IsNull(machine.Context); + } + + private static StateMachine GenerateStateMachineL3(StateMachine machine) + { + machine + .RegisterState(CompositeL3.State1, CompositeL3.State2) + .RegisterComposite(CompositeL3.State2, initialChildStateId: CompositeL3.State2_Sub1, onSuccess: CompositeL3.State3) + .RegisterSubState(CompositeL3.State2_Sub1, parentStateId: CompositeL3.State2, onSuccess: CompositeL3.State2_Sub2) + .RegisterSubComposite(CompositeL3.State2_Sub2, parentStateId: CompositeL3.State2, initialChildStateId: CompositeL3.State2_Sub2_Sub1, onSuccess: CompositeL3.State2_Sub3) + .RegisterSubState(CompositeL3.State2_Sub2_Sub1, parentStateId: CompositeL3.State2_Sub2, onSuccess: CompositeL3.State2_Sub2_Sub2) + .RegisterSubState(CompositeL3.State2_Sub2_Sub2, parentStateId: CompositeL3.State2_Sub2, onSuccess: CompositeL3.State2_Sub2_Sub3) + .RegisterSubState(CompositeL3.State2_Sub2_Sub3, parentStateId: CompositeL3.State2_Sub2, onSuccess: null) + .RegisterSubState(CompositeL3.State2_Sub3, parentStateId: CompositeL3.State2, onSuccess: null) + .RegisterState(CompositeL3.State3, onSuccess: null); + + return machine; + } } diff --git a/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs b/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs index 67080d3..11ad0a6 100644 --- a/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs +++ b/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs @@ -94,8 +94,8 @@ await Assert.ThrowsExactlyAsync(() [TestMethod] [DataRow(false, DisplayName = "Run State2_Sub3")] - [DataRow(true, DisplayName = "Skip State2_Sub3")] - public async Task Composite_Override_Executes_SuccessAsync(bool skipSubState3) + [DataRow(true, DisplayName = "Skip State2_Sub2")] + public async Task Composite_Override_Executes_SuccessAsync(bool skipSubState2) { // Assemble with Dependency Injection var services = new ServiceCollection() @@ -110,7 +110,7 @@ public async Task Composite_Override_Executes_SuccessAsync(bool skipSubState3) var ctxProperties = new PropertyBag() { { ParameterType.Counter, 0 }, - { ParameterType.TestExitEarly2, skipSubState3 }, + { ParameterType.TestExitEarly2, skipSubState2 }, }; var machine = new StateMachine(factory, null, isContextPersistent: true); @@ -130,7 +130,7 @@ public async Task Composite_Override_Executes_SuccessAsync(bool skipSubState3) Assert.IsNotNull(machine); Assert.IsNull(machine.Context); - Assert.AreEqual(skipSubState3 ? 2 : 3, msgService.Counter1, "State Counter1 failed."); + Assert.AreEqual(skipSubState2 ? 3 : 4, msgService.Counter1, "State Counter1 failed."); Assert.AreEqual(0, msgService.Counter2, "State2Dummy should never enter"); Assert.AreEqual(1, msgService.Counter3, "Skip Substate Counter3 failed"); } diff --git a/source/Lite.StateMachine.Tests/TestData/ParameterType.cs b/source/Lite.StateMachine.Tests/TestData/ParameterType.cs index 1083087..a289bb1 100644 --- a/source/Lite.StateMachine.Tests/TestData/ParameterType.cs +++ b/source/Lite.StateMachine.Tests/TestData/ParameterType.cs @@ -8,18 +8,21 @@ public enum ParameterType /// Generic counter. Counter, - /// Tests for DoNotAllowHungStatesTest. - HungStateAvoidance, - /// Random test. KeyTest, + /// Test states executed in order using context.LastStateId or states in a different order. + TestExecutionOrder, + /// Test triggers an early exit. Setting OnSuccess to NULL. TestExitEarly, /// Test triggers a 2nd early exit. Setting OnSuccess to NULL. TestExitEarly2, + /// Tests for DoNotAllowHungStatesTest. + TestHungStateAvoidance, + /// Test trigger to go to an invalid state transition. TestUnregisteredTransition, } diff --git a/source/Lite.StateMachine.Tests/TestData/States/BasicStates.cs b/source/Lite.StateMachine.Tests/TestData/States/BasicStates.cs index f56cc33..144b337 100644 --- a/source/Lite.StateMachine.Tests/TestData/States/BasicStates.cs +++ b/source/Lite.StateMachine.Tests/TestData/States/BasicStates.cs @@ -8,9 +8,21 @@ namespace Lite.StateMachine.Tests.TestData.States; #pragma warning disable SA1649 // File name should match first type name #pragma warning disable SA1402 // File may only contain a single type +#pragma warning disable SA1124 // Do not use regions public class BasicState1() : IState { + #region Suppress CodeMaid Method Sorting + + public Task OnEntering(Context context) + { + context.Parameters[ParameterType.Counter] = context.ParameterAsInt(ParameterType.Counter) + 1; + Console.WriteLine($"[BasicState1][OnEntering] {context.Parameters[ParameterType.Counter]}"); + return Task.CompletedTask; + } + + #endregion Suppress CodeMaid Method Sorting + public async Task OnEnter(Context context) { // Some async work here... @@ -21,13 +33,6 @@ public async Task OnEnter(Context context) Console.WriteLine($"[BasicState1][OnEnter] {context.Parameters[ParameterType.Counter]} => OK"); } - public Task OnEntering(Context context) - { - context.Parameters[ParameterType.Counter] = context.ParameterAsInt(ParameterType.Counter) + 1; - Console.WriteLine($"[BasicState1][OnEntering] {context.Parameters[ParameterType.Counter]}"); - return Task.CompletedTask; - } - public Task OnExit(Context context) { context.Parameters[ParameterType.Counter] = context.ParameterAsInt(ParameterType.Counter) + 1; @@ -38,12 +43,29 @@ public Task OnExit(Context context) public class BasicState2() : IState { + #region Suppress CodeMaid Method Sorting + + public Task OnEntering(Context context) + { + context.Parameters[ParameterType.Counter] = context.ParameterAsInt(ParameterType.Counter) + 1; + Console.WriteLine($"[BasicState2][OnEntering] {context.Parameters[ParameterType.Counter]}"); + return Task.CompletedTask; + } + + #endregion Suppress CodeMaid Method Sorting + public Task OnEnter(Context context) { context.Parameters[ParameterType.Counter] = context.ParameterAsInt(ParameterType.Counter) + 1; + // Assert origin of the previous state + if (context.ParameterAsBool(ParameterType.TestExecutionOrder)) + Assert.AreEqual(BasicStateId.State1, context.PreviousStateId); + else + Assert.AreEqual(BasicStateId.State3, context.PreviousStateId); + // Only move to the next state if we are not testing hanging state avoidance - var testHangingState = context.ParameterAsBool(ParameterType.HungStateAvoidance); + var testHangingState = context.ParameterAsBool(ParameterType.TestHungStateAvoidance); if (!testHangingState) context.NextState(Result.Success); @@ -51,13 +73,6 @@ public Task OnEnter(Context context) return Task.CompletedTask; } - public Task OnEntering(Context context) - { - context.Parameters[ParameterType.Counter] = context.ParameterAsInt(ParameterType.Counter) + 1; - Console.WriteLine($"[BasicState2][OnEntering] {context.Parameters[ParameterType.Counter]}"); - return Task.CompletedTask; - } - public Task OnExit(Context context) { context.Parameters[ParameterType.Counter] = context.ParameterAsInt(ParameterType.Counter) + 1; @@ -68,19 +83,29 @@ public Task OnExit(Context context) public class BasicState3() : IState { - public Task OnEnter(Context context) + #region Suppress CodeMaid Method Sorting + + public Task OnEntering(Context context) { context.Parameters[ParameterType.Counter] = context.ParameterAsInt(ParameterType.Counter) + 1; - context.Parameters[ParameterType.KeyTest] = MessageType.SuccessResponse; - context.NextState(Result.Success); - Console.WriteLine($"[BasicState3][OnEnter] {context.Parameters[ParameterType.Counter]}"); + Console.WriteLine($"[BasicState3][OnEntering] {context.Parameters[ParameterType.Counter]}"); return Task.CompletedTask; } - public Task OnEntering(Context context) + #endregion Suppress CodeMaid Method Sorting + + public Task OnEnter(Context context) { + // Assert origin of the previous state + if (context.ParameterAsBool(ParameterType.TestExecutionOrder)) + Assert.AreEqual(BasicStateId.State2, context.PreviousStateId); + else + Assert.AreEqual(BasicStateId.State1, context.PreviousStateId); + context.Parameters[ParameterType.Counter] = context.ParameterAsInt(ParameterType.Counter) + 1; - Console.WriteLine($"[BasicState3][OnEntering] {context.Parameters[ParameterType.Counter]}"); + context.Parameters[ParameterType.KeyTest] = MessageType.SuccessResponse; + context.NextState(Result.Success); + Console.WriteLine($"[BasicState3][OnEnter] {context.Parameters[ParameterType.Counter]}"); return Task.CompletedTask; } @@ -92,5 +117,6 @@ public Task OnExit(Context context) } } +#pragma warning restore SA1124 // Do not use regions #pragma warning restore SA1649 // File name should match first type name #pragma warning restore SA1402 // File may only contain a single type diff --git a/source/Lite.StateMachine.Tests/TestData/States/CompositeL3DiStates.cs b/source/Lite.StateMachine.Tests/TestData/States/CompositeL3DiStates.cs index e911112..b0fbb74 100644 --- a/source/Lite.StateMachine.Tests/TestData/States/CompositeL3DiStates.cs +++ b/source/Lite.StateMachine.Tests/TestData/States/CompositeL3DiStates.cs @@ -33,6 +33,9 @@ public class State1(IMessageService msg, ILogger log) { public override Task OnEnter(Context context) { + if (context.ParameterAsBool(ParameterType.TestExecutionOrder)) + Assert.IsNull(context.PreviousStateId); + context.Parameters.Add(context.CurrentStateId.ToString(), Guid.NewGuid()); MessageService.AddMessage($"[Keys-{context.CurrentStateId}]: {string.Join(",", context.Parameters.Keys)}"); return base.OnEnter(context); @@ -56,6 +59,9 @@ public override Task OnEntering(Context context) public override Task OnEnter(Context context) { + if (context.ParameterAsBool(ParameterType.TestExecutionOrder)) + Assert.AreEqual(CompositeL3.State1, context.PreviousStateId); + // Demonstrate temporary parameter that will be discarded after State2's OnExit context.Parameters.Add($"{context.CurrentStateId}!TEMP", Guid.NewGuid()); return base.OnEnter(context); @@ -72,7 +78,16 @@ public override Task OnExit(Context context) /// Sublevel-2: State. public class State2_Sub1(IMessageService msg, ILogger log) - : CommonDiStateBase(msg, log); + : CommonDiStateBase(msg, log) +{ + public override Task OnEnter(Context context) + { + if (context.ParameterAsBool(ParameterType.TestExecutionOrder)) + Assert.IsNull(context.PreviousStateId); + + return base.OnEnter(context); + } +} /// Sublevel-2: Composite. public class State2_Sub2(IMessageService msg, ILogger log) @@ -91,6 +106,9 @@ public override Task OnEntering(Context context) public override Task OnEnter(Context context) { + if (context.ParameterAsBool(ParameterType.TestExecutionOrder)) + Assert.AreEqual(CompositeL3.State2_Sub1, context.PreviousStateId); + // Demonstrate temporary parameter that will be discarded after State2_Sub2's OnExit context.Parameters.Add($"{context.CurrentStateId}!TEMP", Guid.NewGuid()); return base.OnEnter(context); @@ -106,7 +124,16 @@ public override Task OnExit(Context context) /// Sublevel-3: State. public class State2_Sub2_Sub1(IMessageService msg, ILogger log) - : CommonDiStateBase(msg, log); + : CommonDiStateBase(msg, log) +{ + public override Task OnEnter(Context context) + { + if (context.ParameterAsBool(ParameterType.TestExecutionOrder)) + Assert.IsNull(context.PreviousStateId); + + return base.OnEnter(context); + } +} /// Sublevel-3: State. public class State2_Sub2_Sub2(IMessageService msg, ILogger log) @@ -114,7 +141,16 @@ public class State2_Sub2_Sub2(IMessageService msg, ILogger log /// Sublevel-3: Last State. public class State2_Sub2_Sub3(IMessageService msg, ILogger log) - : CommonDiStateBase(msg, log); + : CommonDiStateBase(msg, log) +{ + public override Task OnEnter(Context context) + { + if (context.ParameterAsBool(ParameterType.TestExecutionOrder)) + Assert.AreEqual(CompositeL3.State2_Sub2_Sub2, context.PreviousStateId); + + return base.OnEnter(context); + } +} /// Sublevel-2: Last State. public class State2_Sub3(IMessageService msg, ILogger log) @@ -122,6 +158,9 @@ public class State2_Sub3(IMessageService msg, ILogger log) { public override Task OnEnter(Context context) { + if (context.ParameterAsBool(ParameterType.TestExecutionOrder)) + Assert.AreEqual(CompositeL3.State2_Sub2, context.PreviousStateId); + context.Parameters.Add(context.CurrentStateId.ToString(), Guid.NewGuid()); MessageService.AddMessage($"[Keys-{context.CurrentStateId}]: {string.Join(",", context.Parameters.Keys)}"); return base.OnEnter(context); diff --git a/source/Lite.StateMachine.Tests/TestData/States/CustomBasicStates.cs b/source/Lite.StateMachine.Tests/TestData/States/CustomBasicStates.cs index 9a4338e..74a8d8d 100644 --- a/source/Lite.StateMachine.Tests/TestData/States/CustomBasicStates.cs +++ b/source/Lite.StateMachine.Tests/TestData/States/CustomBasicStates.cs @@ -16,8 +16,6 @@ public class State1 : StateBase public override async Task OnEnter(Context ctx) { - int cnt = ctx.ParameterAsInt(ParameterType.Counter); - if (ctx.ParameterAsBool(ParameterType.TestUnregisteredTransition)) ctx.NextStates.OnSuccess = CustomStateId.State2_Unregistered; else @@ -60,6 +58,8 @@ public override Task OnEnter(Context ctx) { _msgService.Counter1++; + Assert.AreEqual(CustomStateId.State1, ctx.PreviousStateId); + if (ctx.ParameterAsBool(ParameterType.TestExitEarly)) ctx.NextStates.OnSuccess = null; @@ -76,7 +76,24 @@ public override Task OnExit(Context context) public class State2Success_Sub1 : StateBase { - public State2Success_Sub1() => HasDebugLogging = true; + private readonly IMessageService _msgService; + + public State2Success_Sub1(IMessageService msg) + { + _msgService = msg; + HasDebugLogging = true; + } + + public override Task OnEnter(Context ctx) + { + _msgService.Counter1++; + + // Skip Sub2 and goto Sub3 + if (ctx.ParameterAsBool(ParameterType.TestExitEarly2)) + ctx.NextStates.OnSuccess = CustomStateId.State2_Sub3; + + return base.OnEnter(ctx); + } } public class State2Success_Sub2 : StateBase @@ -92,10 +109,6 @@ public State2Success_Sub2(IMessageService msg) public override Task OnEnter(Context ctx) { _msgService.Counter1++; - - if (ctx.ParameterAsBool(ParameterType.TestExitEarly2)) - ctx.NextStates.OnSuccess = null; - return base.OnEnter(ctx); } } @@ -112,6 +125,11 @@ public State2Success_Sub3(IMessageService msg) public override Task OnEnter(Context ctx) { + if (ctx.ParameterAsBool(ParameterType.TestExitEarly2)) + Assert.AreEqual(CustomStateId.State2_Sub1, ctx.PreviousStateId); + else + Assert.AreEqual(CustomStateId.State2_Sub2, ctx.PreviousStateId); + _msgService.Counter1++; return base.OnEnter(ctx); } @@ -129,6 +147,8 @@ public State3(IMessageService msg) public override Task OnEnter(Context ctx) { + Assert.AreEqual(CustomStateId.State2_Success, ctx.PreviousStateId); + _msgService.Counter3++; return base.OnEnter(ctx); } diff --git a/source/Lite.StateMachine/Context.cs b/source/Lite.StateMachine/Context.cs index 6ad01c6..fb4b80b 100644 --- a/source/Lite.StateMachine/Context.cs +++ b/source/Lite.StateMachine/Context.cs @@ -47,12 +47,12 @@ internal Context( /// Gets result emitted by the last child state (for composite parents only). public Result? LastChildResult { get; } - /////// Gets the previous state's enum value. - ////public TStateId PreviousState { get; internal set; } - /// Gets or sets an arbitrary parameter provided by caller to the current action. public PropertyBag Parameters { get; set; } = []; + /// Gets the previous state's enum value. + public TStateId? PreviousStateId { get; internal set; } + /// Signal the machine to move forward (only once per state entry). /// Result to pass to the next state. /// Consider renaming to `StateResult` or `Result` for clarity. diff --git a/source/Lite.StateMachine/StateMachine.cs b/source/Lite.StateMachine/StateMachine.cs index 715d3f3..755160f 100644 --- a/source/Lite.StateMachine/StateMachine.cs +++ b/source/Lite.StateMachine/StateMachine.cs @@ -205,11 +205,13 @@ public async Task> RunAsync( if (!_states.ContainsKey(initialStateId)) throw new MissingInitialStateException($"Initial state '{initialStateId}' was not registered."); - var current = initialStateId; + TStateId? prevStateId = null; + var currentStateId = initialStateId; while (!cancellationToken.IsCancellationRequested) { - var reg = GetRegistration(current); + var reg = GetRegistration(currentStateId); + reg.PreviousStateId = prevStateId; // TODO (2025-12-28 DS): Configure context and pass it along ////var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -231,7 +233,8 @@ public async Task> RunAsync( if (nextId is null) break; - current = nextId.Value; + prevStateId = currentStateId; + currentStateId = nextId.Value; } return this; @@ -310,6 +313,7 @@ private StateRegistration GetRegistration(TStateId stateId) { Parameters = parameters, Errors = errors, + PreviousStateId = reg.PreviousStateId, }; await instance.OnEntering(parentEnterCtx).ConfigureAwait(false); @@ -337,10 +341,14 @@ private StateRegistration GetRegistration(TStateId stateId) var childId = reg.InitialChildId.Value; Result? lastChildResult = null; + // Set the initial substate's PreviousStateId to NULL, as we already know the parent. + TStateId? childPrevStateId = null; + // Composite Loop while (!ct.IsCancellationRequested) { var childReg = GetRegistration(childId); + childReg.PreviousStateId = childPrevStateId; // The child state was not registered the specified composite parent state if (!Equals(childReg.ParentId, reg.StateId)) @@ -371,6 +379,7 @@ private StateRegistration GetRegistration(TStateId stateId) throw new DisjointedNextSubStateException($"Child '{childId}' maps to '{nextChildId}', which is not a sibling under '{reg.StateId}'."); // Proceed to the next substate + childPrevStateId = childId; childId = nextChildId.Value; } @@ -433,6 +442,7 @@ private StateRegistration GetRegistration(TStateId stateId) { Parameters = parameterStack ?? [], Errors = errorStack ?? [], + PreviousStateId = reg.PreviousStateId, }; IDisposable? subscription = null; diff --git a/source/Lite.StateMachine/StateRegistration.cs b/source/Lite.StateMachine/StateRegistration.cs index af10d96..90cc6b2 100644 --- a/source/Lite.StateMachine/StateRegistration.cs +++ b/source/Lite.StateMachine/StateRegistration.cs @@ -32,10 +32,9 @@ internal sealed class StateRegistration /// Gets the sub-state's parent State Id (optional). public TStateId? ParentId { get; init; } + /// Gets or sets the Previous we transitioned from. + public TStateId? PreviousStateId { get; set; } + /// Gets the State Id, used by ExportUml for . public TStateId StateId { get; init; } - - //// INFO: Though dict lookup is more flexible, properties are faster. Leaving this here for reference. - /////// Gets the state result transitions OnSuccess, OnError, OnFailure. - ////public Dictionary Transitions { get; } = new(); } diff --git a/source/Sample.Basics/States/DemoMachine.cs b/source/Sample.Basics/States/DemoMachine.cs index b4e1d09..cdaa5cf 100644 --- a/source/Sample.Basics/States/DemoMachine.cs +++ b/source/Sample.Basics/States/DemoMachine.cs @@ -19,7 +19,8 @@ public static class DemoMachine public static async Task RunAsync(bool logOutput = true) { var counter = 0; - var ctxProperties = new PropertyBag() { + var ctxProperties = new PropertyBag() + { { ParameterType.Counter, counter }, { ParameterType.LogOutput, logOutput }, };