From b1595f531987076987d92c33c077d88c23c833da Mon Sep 17 00:00:00 2001 From: Damian Suess Date: Sat, 10 Jan 2026 10:45:17 -0500 Subject: [PATCH] New test for CancelsInfiniteStateMachineTestAsync. Context is not null after exiting --- .../StateTests/BasicStateTests.cs | 31 +++---- .../StateTests/CommandStateTests.cs | 91 ++++++++++++++++++- .../StateTests/CompositeStateTest.cs | 18 ++-- .../StateTests/ContextTests.cs | 7 +- .../StateTests/CustomStateTests.cs | 9 +- .../StateTests/DiMsTests.cs | 11 ++- .../StateTests/TestBase.cs | 9 +- .../TestData/Models/CustomCommands.cs | 17 +++- source/Lite.StateMachine/StateMachine.cs | 15 ++- 9 files changed, 150 insertions(+), 58 deletions(-) diff --git a/source/Lite.StateMachine.Tests/StateTests/BasicStateTests.cs b/source/Lite.StateMachine.Tests/StateTests/BasicStateTests.cs index f3979c8..e5e05b4 100644 --- a/source/Lite.StateMachine.Tests/StateTests/BasicStateTests.cs +++ b/source/Lite.StateMachine.Tests/StateTests/BasicStateTests.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Lite.StateMachine.Tests.TestData; using Lite.StateMachine.Tests.TestData.States; @@ -10,10 +11,8 @@ namespace Lite.StateMachine.Tests.StateTests; [TestClass] -public class BasicStateTests +public class BasicStateTests : TestBase { - public TestContext TestContext { get; set; } - /// Standard synchronous state registration exiting to completion. [TestMethod] public void Basic_RegisterState_Executes123_SuccessTest() @@ -35,8 +34,9 @@ public void Basic_RegisterState_Executes123_SuccessTest() task.GetAwaiter().GetResult(); // Assert Results - Assert.IsNotNull(machine); - Assert.IsNull(machine.Context); + AssertMachineNotNull(machine); + + Assert.AreEqual(0, machine.Context.Parameters.Count); // Ensure all states are registered var enums = Enum.GetValues().Cast(); @@ -68,8 +68,7 @@ public async Task Basic_RegisterState_Executes123_SuccessTestAsync() await machine.RunAsync(BasicStateId.State1, ctxProperties); // Assert Results - Assert.IsNotNull(machine); - Assert.IsNull(machine.Context); + AssertMachineNotNull(machine); // Ensure all states are registered var enums = Enum.GetValues().Cast(); @@ -94,8 +93,7 @@ public async Task Basic_RegisterState_Executes132_SuccessTestAsync() await machine.RunAsync(BasicStateId.State1, ctxProperties); // Assert Results - Assert.IsNotNull(machine); - Assert.IsNull(machine.Context); + AssertMachineNotNull(machine); // Ensure all states are registered var enums = Enum.GetValues().Cast(); @@ -126,8 +124,7 @@ public async Task Basic_RegisterState_Fluent_SuccessTestAsync() .RunAsync(BasicStateId.State1, ctxProperties, cancellationToken: TestContext.CancellationToken); // Assert Results - Assert.IsNotNull(machine); - Assert.IsNull(machine.Context); + AssertMachineNotNull(machine); // Ensure all states are registered var enums = Enum.GetValues().Cast(); @@ -190,8 +187,7 @@ public async Task HungState_Proceeds_DefaultStateTimeout_SuccessTestAsync() await machine.RunAsync(BasicStateId.State1, ctxProperties); // Assert Results - Assert.IsNotNull(machine); - Assert.IsNull(machine.Context); + AssertMachineNotNull(machine); // Ensure all states are registered var enums = Enum.GetValues().Cast(); @@ -205,8 +201,6 @@ 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 ctxProperties = new PropertyBag() { @@ -224,15 +218,14 @@ public async Task RegisterState_ReturnsContext_SuccessTestAsync() await task; // Non async method: task.GetAwaiter().GetResult(); // Assert Results - Assert.IsNotNull(machine); - Assert.IsNotNull(machine.Context); + AssertMachineNotNull(machine); var ctxFinalParams = machine.Context.Parameters; Assert.IsNotNull(ctxFinalParams); - Assert.AreEqual(TestValue, ctxFinalParams[ParameterType.KeyTest]); + Assert.AreNotEqual(0, ctxFinalParams.Count); // 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[ParameterType.Counter]); + ////Assert.AreEqual(9, ctxFinalParams[ParameterType.Counter]); } } diff --git a/source/Lite.StateMachine.Tests/StateTests/CommandStateTests.cs b/source/Lite.StateMachine.Tests/StateTests/CommandStateTests.cs index 7a5e325..ebd9f53 100644 --- a/source/Lite.StateMachine.Tests/StateTests/CommandStateTests.cs +++ b/source/Lite.StateMachine.Tests/StateTests/CommandStateTests.cs @@ -2,6 +2,8 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Lite.StateMachine.Tests.TestData; using Lite.StateMachine.Tests.TestData.Models; @@ -82,12 +84,97 @@ public async Task BasicState_Override_Executes_SuccessAsync() await machine.RunAsync(StateId.State1, ctxProperties, null, TestContext.CancellationToken); // Assert Results - Assert.IsNotNull(machine); - Assert.IsNull(machine.Context); + AssertMachineNotNull(machine); Assert.AreEqual(29, msgService.Counter1); Assert.AreEqual(13, msgService.Counter2, "State2 Context.Param Count"); Assert.AreEqual(12, msgService.Counter3); Assert.AreEqual(2, msgService.Counter4); } + + [TestMethod] + public async Task CancelsInfiniteStateMachineTestAsync() + { + // Assemble with Dependency Injection + var services = new ServiceCollection() + .AddLogging(InlineTraceLogger(LogLevel.Trace)) + .AddSingleton() + .AddSingleton() + .BuildServiceProvider(); + + var msgService = services.GetRequiredService(); + var events = services.GetRequiredService(); + + Func factory = t => ActivatorUtilities.CreateInstance(services, t); + + var machine = new StateMachine(factory, events) + .RegisterState(StateId.State1, StateId.State2) + .RegisterState(StateId.State2, StateId.State1); + + var cts = new CancellationTokenSource(); + + var counter = 0; + events.Subscribe(msg => + { + // All messages get received, even `CancelResponse` + if (msg is not CancelCommand cmd) + return; + + counter++; + if (counter >= 100) + { + // Get outta here!! + cts.Cancel(); + } + + // Don't let it hang waiting for a response + events.Publish(new CancelResponse()); + }); + + var result = await machine.RunAsync(StateId.State1, null, null, cts.Token); + + // Assert + Assert.IsNotNull(result); + AssertMachineNotNull(machine); + Assert.AreEqual(100, counter); + } + + private class InfState1 : IState + { + public Task OnEnter(Context context) + { + context.NextState(Result.Success); + return Task.CompletedTask; + } + + public Task OnEntering(Context context) => Task.CompletedTask; + + public Task OnExit(Context context) => Task.CompletedTask; + } + + private class InfState2 : ICommandState + { + public IReadOnlyCollection SubscribedMessageTypes => + [ + typeof(CancelResponse), + ]; + + public Task OnEnter(Context context) + { + context.EventAggregator?.Publish(new CancelCommand()); + return Task.CompletedTask; + } + + public Task OnEntering(Context context) => Task.CompletedTask; + + public Task OnExit(Context context) => Task.CompletedTask; + + public Task OnMessage(Context context, object message) + { + context.NextState(Result.Success); + return Task.CompletedTask; + } + + public Task OnTimeout(Context context) => Task.CompletedTask; + } } diff --git a/source/Lite.StateMachine.Tests/StateTests/CompositeStateTest.cs b/source/Lite.StateMachine.Tests/StateTests/CompositeStateTest.cs index dc6e2e4..2545b92 100644 --- a/source/Lite.StateMachine.Tests/StateTests/CompositeStateTest.cs +++ b/source/Lite.StateMachine.Tests/StateTests/CompositeStateTest.cs @@ -54,8 +54,7 @@ public async Task Level1_Basic_RegisterHelpers_SuccessTestAsync() Console.WriteLine(umlBasic); // Assert - Assert.IsNotNull(machine); - Assert.IsNull(machine.Context); + AssertMachineNotNull(machine); // Ensure all states are hit var enums = Enum.GetValues().Cast(); @@ -82,8 +81,7 @@ public async Task Level1_Basic_RegisterState_SuccessTestAsync() await machine.RunAsync(CompositeL1StateId.State1); // Assert - Assert.IsNotNull(machine); - Assert.IsNull(machine.Context); + AssertMachineNotNull(machine); // Ensure all states are hit var enums = Enum.GetValues().Cast(); @@ -132,8 +130,7 @@ public async Task Level1_Fluent_RegisterHelpers_SuccessTestAsync() .RunAsync(CompositeL1StateId.State1, cancellationToken: TestContext.CancellationToken); // Assert - Assert.IsNotNull(machine); - Assert.IsNull(machine.Context); + AssertMachineNotNull(machine); // Ensure all states are hit var enums = Enum.GetValues().Cast(); @@ -159,8 +156,7 @@ public void Level1_Fluent_RegisterState_SuccessTest() .GetResult(); // Assert - Assert.IsNotNull(machine); - Assert.IsNull(machine.Context); + AssertMachineNotNull(machine); // Ensure all states are hit var enums = Enum.GetValues().Cast(); @@ -223,8 +219,7 @@ public async Task Level3_IsContextPersistent_False_SuccessTestAsync(bool context await machine.RunAsync(CompositeL3.State1, cancellationToken: TestContext.CancellationToken); // Assert - Assert.IsNotNull(machine); - Assert.IsNull(machine.Context); + AssertMachineNotNull(machine); // Ensure all states are registered var enums = Enum.GetValues().Cast(); @@ -284,8 +279,7 @@ public async Task Level3_PreviousStateId_SuccessTestAsync() await machine.RunAsync(CompositeL3.State1, ctxProperties, null, TestContext.CancellationToken); // Assert - Assert.IsNotNull(machine); - Assert.IsNull(machine.Context); + AssertMachineNotNull(machine); } private static StateMachine GenerateStateMachineL3(StateMachine machine) diff --git a/source/Lite.StateMachine.Tests/StateTests/ContextTests.cs b/source/Lite.StateMachine.Tests/StateTests/ContextTests.cs index e7c4e41..cfe142c 100644 --- a/source/Lite.StateMachine.Tests/StateTests/ContextTests.cs +++ b/source/Lite.StateMachine.Tests/StateTests/ContextTests.cs @@ -9,7 +9,7 @@ namespace Lite.StateMachine.Tests.StateTests; [TestClass] -public class ContextTests +public class ContextTests : TestBase { public const string ParameterCounter = "Counter"; public const string ParameterKeyTest = "TestKey"; @@ -29,8 +29,6 @@ private enum ParameterType Param3, } - public TestContext TestContext { get; set; } - /// Standard synchronous state registration exiting to completion. [TestMethod] public void Basic_RegisterState_Executes123_SuccessTest() @@ -48,8 +46,7 @@ public void Basic_RegisterState_Executes123_SuccessTest() task.GetAwaiter().GetResult(); // Assert Results - Assert.IsNotNull(machine); - Assert.IsNull(machine.Context); + AssertMachineNotNull(machine); // Ensure all states are registered var enums = Enum.GetValues().Cast(); diff --git a/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs b/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs index 0c7b9df..58fac61 100644 --- a/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs +++ b/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs @@ -45,8 +45,7 @@ public async Task BasicState_Override_Executes_SuccessAsync(bool skipState3) await machine.RunAsync(CustomStateId.State1, ctxProperties, cancellationToken: TestContext.CancellationToken); // Assert Results - Assert.IsNotNull(machine); - Assert.IsNull(machine.Context); + AssertMachineNotNull(machine); Assert.AreEqual(1, msgService.Counter1); Assert.AreEqual(0, msgService.Counter2, "State2Dummy should never enter"); @@ -83,8 +82,7 @@ await Assert.ThrowsExactlyAsync(() => machine.RunAsync(CustomStateId.State1, ctxProperties, null, TestContext.CancellationToken)); // Assert Results - Assert.IsNotNull(machine); - Assert.IsNull(machine.Context); + AssertMachineNotNull(machine); Assert.AreEqual(0, msgService.Counter1); Assert.AreEqual(0, msgService.Counter2, "State2Dummy should never enter"); @@ -125,8 +123,7 @@ public async Task Composite_Override_Executes_SuccessAsync(bool skipSubState2) await machine.RunAsync(CustomStateId.State1, ctxProperties, cancellationToken: TestContext.CancellationToken); // Assert Results - Assert.IsNotNull(machine); - Assert.IsNull(machine.Context); + AssertMachineNotNull(machine); Assert.AreEqual(skipSubState2 ? 3 : 4, msgService.Counter1, "State Counter1 failed."); Assert.AreEqual(0, msgService.Counter2, "State2Dummy should never enter"); diff --git a/source/Lite.StateMachine.Tests/StateTests/DiMsTests.cs b/source/Lite.StateMachine.Tests/StateTests/DiMsTests.cs index 463a7bd..48bc1c0 100644 --- a/source/Lite.StateMachine.Tests/StateTests/DiMsTests.cs +++ b/source/Lite.StateMachine.Tests/StateTests/DiMsTests.cs @@ -18,7 +18,7 @@ namespace Lite.StateMachine.Tests.StateTests; /// Microsoft Dependency Injection Tests. [TestClass] [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1124:Do not use regions", Justification = "Allowed for this test class")] -public class DiMsTests +public class DiMsTests : TestBase { [TestMethod] public async Task Basic_FlatStates_SuccessTestAsync() @@ -44,8 +44,7 @@ public async Task Basic_FlatStates_SuccessTestAsync() var result = await machine.RunAsync(BasicStateId.State1); Assert.IsNotNull(result); - Assert.IsNotNull(machine); - Assert.IsNull(machine.Context); + AssertMachineNotNull(machine); var msgService = services.GetRequiredService(); Assert.AreEqual(9, msgService.Counter1, "Message service should have 9 from the 3 states."); @@ -142,8 +141,7 @@ public async Task Basic_LogLevelNone_SuccessTestAsync() var result = await machine.RunAsync(BasicStateId.State1); Assert.IsNotNull(result); - Assert.IsNotNull(machine); - Assert.IsNull(machine.Context); + AssertMachineNotNull(machine); // Ensure all states are registered var enums = Enum.GetValues().Cast(); @@ -277,6 +275,9 @@ public async Task RegisterState_MsDi_EventAggregatorOnly_SuccessTestAsync() Console.WriteLine("MS.DI workflow finished."); + // Assert + AssertMachineNotNull(machine); + Assert.AreEqual(2, msgService.Counter2); Assert.AreEqual(42, msgService.Counter1); } diff --git a/source/Lite.StateMachine.Tests/StateTests/TestBase.cs b/source/Lite.StateMachine.Tests/StateTests/TestBase.cs index 241d697..0d385ff 100644 --- a/source/Lite.StateMachine.Tests/StateTests/TestBase.cs +++ b/source/Lite.StateMachine.Tests/StateTests/TestBase.cs @@ -9,7 +9,14 @@ namespace Lite.StateMachine.Tests.StateTests; public class TestBase { - public TestContext TestContext { get; set; } + public required TestContext TestContext { get; set; } + + protected static void AssertMachineNotNull(Lite.StateMachine.StateMachine machine) + where T : struct, Enum + { + Assert.IsNotNull(machine); + Assert.IsNotNull(machine.Context); + } /// ILogger Helper for generating clean in-line logs. /// Log level (Default: Trace). diff --git a/source/Lite.StateMachine.Tests/TestData/Models/CustomCommands.cs b/source/Lite.StateMachine.Tests/TestData/Models/CustomCommands.cs index b60b422..b394275 100644 --- a/source/Lite.StateMachine.Tests/TestData/Models/CustomCommands.cs +++ b/source/Lite.StateMachine.Tests/TestData/Models/CustomCommands.cs @@ -9,20 +9,27 @@ namespace Lite.StateMachine.Tests.TestData.Models; /// Signifies it's one of our event packets. public interface ICustomCommand; -/// Sample command sent by state machine. -public class UnlockCommand : ICustomCommand +public class CancelCommand : ICustomCommand { - public int Counter { get; set; } = 0; + public int Counter { get; set; } } +public class CancelResponse : ICustomCommand; + /// Sample command response received by state machine. -public class UnlockResponse : ICustomCommand +public class CloseResponse : ICustomCommand +{ + public int Counter { get; set; } = 0; +} + +/// Sample command sent by state machine. +public class UnlockCommand : ICustomCommand { public int Counter { get; set; } = 0; } /// Sample command response received by state machine. -public class CloseResponse : ICustomCommand +public class UnlockResponse : ICustomCommand { public int Counter { get; set; } = 0; } diff --git a/source/Lite.StateMachine/StateMachine.cs b/source/Lite.StateMachine/StateMachine.cs index 958a0d1..a16be39 100644 --- a/source/Lite.StateMachine/StateMachine.cs +++ b/source/Lite.StateMachine/StateMachine.cs @@ -65,7 +65,8 @@ public StateMachine( } /// - public Context Context { get; private set; } = default!; + public Context Context { get; private set; } = new Context(default, default, default!, null); + ////public Context Context { get; private set; } = default!; /// public int DefaultCommandTimeoutMs { get; set; } = 3000; @@ -209,6 +210,9 @@ public async Task> RunAsync( TStateId? prevStateId = null; var currentStateId = initialStateId; + // TBD + ////Context = new Context(currentStateId, default, default!, null); + while (!cancellationToken.IsCancellationRequested) { var reg = GetRegistration(currentStateId); @@ -222,11 +226,15 @@ public async Task> RunAsync( //// Errors = errorStack ?? [], ////}; + ////Context.Parameters = parameterStack ?? []; + ////Context.Errors = errorStack ?? []; + parameterStack ??= []; errorStack ??= []; // Run any state (composite or leaf) recursively. var result = await RunAnyStateRecursiveAsync(reg, parameterStack, errorStack, cancellationToken).ConfigureAwait(false); + ////var result = await RunAnyStateRecursiveAsync(reg, cancellationToken).ConfigureAwait(false); if (result is null) break; @@ -292,11 +300,12 @@ private StateRegistration GetRegistration(TStateId stateId) CancellationToken ct) { // Ensure we always operate on non-null, shared bags - parameters ??= []; - errors ??= []; + ////parameters ??= []; + ////errors ??= []; // Run Normal or Command State if (!reg.IsCompositeParent) + ////return await RunLeafAsync(reg, ct).ConfigureAwait(false); return await RunLeafAsync(reg, parameters, errors, ct).ConfigureAwait(false); // Composite States