From 4fb3ef12e6c786f2b566412e4f9ad19a93b3cfc8 Mon Sep 17 00:00:00 2001 From: Damian Suess Date: Sat, 3 Jan 2026 11:40:38 -0500 Subject: [PATCH 1/3] Added `NextStates` to Context for custom NextState(T) overrides (WIP). Added test cases. --- .../StateTests/CustomStateTests.cs | 53 +++++++++++++++++++ .../TestData/ParameterType.cs | 13 +++-- .../TestData/{States => }/StateEnums.cs | 15 +++++- .../TestData/States/CustomBasicStates.cs | 49 +++++++++++++++++ .../TestData/States/StateBase.cs | 24 +++++++-- source/Lite.StateMachine/Context.cs | 16 +++--- .../Lite.StateMachine.csproj | 11 ++-- source/Lite.StateMachine/StateMachine.cs | 23 ++++++-- 8 files changed, 180 insertions(+), 24 deletions(-) create mode 100644 source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs rename source/Lite.StateMachine.Tests/TestData/{States => }/StateEnums.cs (76%) create mode 100644 source/Lite.StateMachine.Tests/TestData/States/CustomBasicStates.cs diff --git a/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs b/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs new file mode 100644 index 0000000..a7b3b02 --- /dev/null +++ b/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs @@ -0,0 +1,53 @@ +// Copyright Xeno Innovations, Inc. 2025 +// See the LICENSE file in the project root for more information. + +using System.Threading.Tasks; +using Lite.StateMachine.Tests.TestData; +using Lite.StateMachine.Tests.TestData.States.CustomBasicStates; + +namespace Lite.StateMachine.Tests.StateTests; + +[TestClass] +public class CustomStateTests +{ + public TestContext TestContext { get; set; } + + [TestMethod] + public async Task BasicState_Overrides_OnSuccess_SuccessAsync() + { + // Assemble + var counter = 0; + var ctxProperties = new PropertyBag() { { ParameterType.Counter, counter } }; + + var machine = new StateMachine(); + machine.RegisterState(CustomBasicStateId.State1, CustomBasicStateId.State2_Dummy); + machine.RegisterState(CustomBasicStateId.State2_Dummy, CustomBasicStateId.State3); + machine.RegisterState(CustomBasicStateId.State2_SuccessA, CustomBasicStateId.State3); + machine.RegisterState(CustomBasicStateId.State3); + + // Act - Start your engine! + await machine.RunAsync(CustomBasicStateId.State1, ctxProperties, cancellationToken: TestContext.CancellationToken); + + // Assert Results + Assert.IsNotNull(machine); + Assert.IsNull(machine.Context); + + /* + // Ensure all states are registered + var enums = Enum.GetValues() + .Cast(); + + Assert.AreEqual(enums.Count(), machine.States.Count()); + Assert.IsTrue(enums.All(k => machine.States.Contains(k))); + + // Ensure they're registered in order + Assert.IsTrue(enums.SequenceEqual(machine.States), "States should be registered for execution in the same order as the defined enums, StateId 1 => 2 => 3."); + */ + } + + [TestMethod] + [Ignore] + public void BasicState_Overrides_OnSuccessOnError_OnFailure_SuccessAsync() + { + } +} diff --git a/source/Lite.StateMachine.Tests/TestData/ParameterType.cs b/source/Lite.StateMachine.Tests/TestData/ParameterType.cs index 735ec89..4c615bf 100644 --- a/source/Lite.StateMachine.Tests/TestData/ParameterType.cs +++ b/source/Lite.StateMachine.Tests/TestData/ParameterType.cs @@ -3,9 +3,14 @@ namespace Lite.StateMachine.Tests.TestData; -public static class ParameterType +public enum ParameterType { - public const string Counter = "Counter"; - public const string HungStateAvoidance = "DoNotAllowHungStatesTest"; - public const string KeyTest = "TestKey"; + /// Generic counter. + Counter, + + /// Tests for DoNotAllowHungStatesTest. + HungStateAvoidance, + + /// Random test. + KeyTest, } diff --git a/source/Lite.StateMachine.Tests/TestData/States/StateEnums.cs b/source/Lite.StateMachine.Tests/TestData/StateEnums.cs similarity index 76% rename from source/Lite.StateMachine.Tests/TestData/States/StateEnums.cs rename to source/Lite.StateMachine.Tests/TestData/StateEnums.cs index 2c50f98..e560efd 100644 --- a/source/Lite.StateMachine.Tests/TestData/States/StateEnums.cs +++ b/source/Lite.StateMachine.Tests/TestData/StateEnums.cs @@ -1,7 +1,7 @@ // Copyright Xeno Innovations, Inc. 2025 // See the LICENSE file in the project root for more information. -namespace Lite.StateMachine.Tests.TestData.States; +namespace Lite.StateMachine.Tests.TestData; #pragma warning disable SA1649 // File name should match first type name @@ -46,4 +46,17 @@ public enum CompositeMsgStateId Failure, } +public enum CustomBasicStateId +{ + State1, + State2_Dummy, + State2_SuccessA, + State2_SuccessB, + State2_ErrorA, + State2_ErrorB, + State2_FailureA, + State2_FailureB, + State3, +} + #pragma warning restore SA1649 // File name should match first type name diff --git a/source/Lite.StateMachine.Tests/TestData/States/CustomBasicStates.cs b/source/Lite.StateMachine.Tests/TestData/States/CustomBasicStates.cs new file mode 100644 index 0000000..b436383 --- /dev/null +++ b/source/Lite.StateMachine.Tests/TestData/States/CustomBasicStates.cs @@ -0,0 +1,49 @@ +// Copyright Xeno Innovations, Inc. 2025 +// See the LICENSE file in the project root for more information. + +using System.Threading.Tasks; + +#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 IDE0130 // Namespace does not match folder structure + +namespace Lite.StateMachine.Tests.TestData.States.CustomBasicStates; + +public class State1 : StateBase +{ + public State1() => HasDebugLogging = true; + + public override async Task OnEnter(Context context) + { + int cnt = context.ParameterAsInt(ParameterType.Counter); + + context.NextStates[Result.Success] = CustomBasicStateId.State2_SuccessA; + + // vNext: Cycle through each of the OnSuccess/OnExit/OnFailure + ////if (cnt == 0) + //// context.NextStates[Result.Success] = CustomBasicStateId.State2_SuccessA; + ////else if (cnt == 1) + //// context.NextStates[Result.Success] = CustomBasicStateId.State2_SuccessB; + + await base.OnEnter(context); + } +} + +public class State2Dummy : StateBase +{ + public State2Dummy() => HasDebugLogging = true; +} + +public class State2SuccessA : StateBase +{ + public State2SuccessA() => HasDebugLogging = true; +} + +public class State3 : StateBase +{ + public State3() => HasDebugLogging = true; +} + +#pragma warning restore IDE0130 // Namespace does not match folder structure +#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/StateBase.cs b/source/Lite.StateMachine.Tests/TestData/States/StateBase.cs index a3401aa..70c9371 100644 --- a/source/Lite.StateMachine.Tests/TestData/States/StateBase.cs +++ b/source/Lite.StateMachine.Tests/TestData/States/StateBase.cs @@ -2,6 +2,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.Diagnostics; using System.Threading.Tasks; namespace Lite.StateMachine.Tests.TestData.States; @@ -9,15 +10,30 @@ namespace Lite.StateMachine.Tests.TestData.States; public class StateBase : IState where TStateId : struct, Enum { + public bool HasDebugLogging { get; set; } = false; + public virtual Task OnEnter(Context context) { + if (HasDebugLogging) + Debug.WriteLine($"[{GetType().Name}] [OnEnter]"); + context.NextState(Result.Success); return Task.CompletedTask; } - public virtual Task OnEntering(Context context) => - Task.CompletedTask; + public virtual Task OnEntering(Context context) + { + if (HasDebugLogging) + Debug.WriteLine($"[{GetType().Name}] [OnEntering]"); + + return Task.CompletedTask; + } + + public virtual Task OnExit(Context context) + { + if (HasDebugLogging) + Debug.WriteLine($"[{GetType().Name}] [OnExit]"); - public virtual Task OnExit(Context context) => - Task.CompletedTask; + return Task.CompletedTask; + } } diff --git a/source/Lite.StateMachine/Context.cs b/source/Lite.StateMachine/Context.cs index 5a00372..0c2c314 100644 --- a/source/Lite.StateMachine/Context.cs +++ b/source/Lite.StateMachine/Context.cs @@ -4,6 +4,7 @@ namespace Lite.StateMachine; using System; +using System.Collections.Generic; using System.Threading.Tasks; /// Context passed to every state. Provides a "Parameter" and a NextState(Result) trigger. @@ -15,11 +16,13 @@ public sealed class Context internal Context( TStateId current, + Dictionary nextStates, TaskCompletionSource tcs, IEventAggregator? eventAggregator = null, Result? lastChildResult = null) { CurrentStateId = current; + NextStates = nextStates; _tcs = tcs; EventAggregator = eventAggregator; LastChildResult = lastChildResult; @@ -38,7 +41,10 @@ internal Context( public Result? LastChildResult { get; } /////// Gets the previous state's enum value. - ////public TStateId LastStateId { get; internal set; } + ////public TStateId PreviousState { get; internal set; } + + /// Gets or sets the next transition for previewing and overriding. + public Dictionary NextStates { get; set; } = []; /// Gets or sets an arbitrary parameter provided by caller to the current action. public PropertyBag Parameters { get; set; } = []; @@ -47,14 +53,6 @@ internal Context( /// Result to pass to the next state. public void NextState(Result result) => _tcs.TrySetResult(result); - /// Override the previously defined transition with the one specified. - /// On value. - /// New to transition to. - public void OnTransition(Result result, TStateId newStateId) - { - throw new NotImplementedException(); - } - public bool ParameterAsBool(object key, bool defaultBool = false) { if (Parameters.TryGetValue(key, out var value) && value is bool boolValue) diff --git a/source/Lite.StateMachine/Lite.StateMachine.csproj b/source/Lite.StateMachine/Lite.StateMachine.csproj index 9f72f4f..609fd35 100644 --- a/source/Lite.StateMachine/Lite.StateMachine.csproj +++ b/source/Lite.StateMachine/Lite.StateMachine.csproj @@ -11,7 +11,7 @@ 2.2.0 $(AssemblyVersion) $(AssemblyVersion) - -alpha1 + -alpha2 $(VersionPrefix)$(VersionSuffix) True @@ -21,10 +21,15 @@ https://github.com/SuessLabs/Lite.StateMachine statemachine;lite;fsm;suesslabs;xeno-innovations LICENSE.md - New features in v2.1: + New Features in v2.2: + +* New: Custom override NextState Transition +* New: Renamed, Result.Ok -> Result.Success (Breaking Change) + +New Features in v2.1: * Proprety: IsContextPersistent - Auto-cleans Context parameters added by substates. -* Renamed `RegisterCompositeChild(...)` to `RegisterSubComposite(...)` +* Renamed `RegisterCompositeChild(...)` to `RegisterSubComposite(...)` (breaking change) diff --git a/source/Lite.StateMachine/StateMachine.cs b/source/Lite.StateMachine/StateMachine.cs index f4eaf5a..681a48f 100644 --- a/source/Lite.StateMachine/StateMachine.cs +++ b/source/Lite.StateMachine/StateMachine.cs @@ -301,8 +301,15 @@ private StateRegistration GetRegistration(TStateId stateId) // Composite States var instance = GetOrCreateInstance(reg); + Dictionary nextStates = new() + { + { Result.Success, reg.OnSuccess }, + { Result.Error, reg.OnError }, + { Result.Failure, reg.OnFailure }, + }; + var parentEnterTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var parentEnterCtx = new Context(reg.StateId, parentEnterTcs, _eventAggregator) + var parentEnterCtx = new Context(reg.StateId, nextStates, parentEnterTcs, _eventAggregator) { Parameters = parameters, Errors = errors, @@ -348,6 +355,7 @@ private StateRegistration GetRegistration(TStateId stateId) if (childResult is null) return null; + // TODO (#76): Extract the Context.OnSuccess/Error/Failure override (if any) lastChildResult = childResult; var nextChildId = ResolveNext(childReg, childResult.Value); @@ -367,7 +375,7 @@ private StateRegistration GetRegistration(TStateId stateId) // Parent's OnExit decides Ok/Error/Failure; Inform parent of last child's result via Context // TODO (2025-12-28 DS): Pass one Context object. Just clear "lastChildResult" after the OnExit. var parentExitTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var parentExitCtx = new Context(reg.StateId, parentExitTcs, _eventAggregator, lastChildResult) + var parentExitCtx = new Context(reg.StateId, nextStates, parentExitTcs, _eventAggregator, lastChildResult) { Parameters = parameters ?? [], Errors = errors ?? [], @@ -409,8 +417,17 @@ private StateRegistration GetRegistration(TStateId stateId) CancellationToken cancellationToken) { IState instance = GetOrCreateInstance(reg); + + // Next state transitions + Dictionary nextStates = new() + { + { Result.Success, reg.OnSuccess }, + { Result.Error, reg.OnError }, + { Result.Failure, reg.OnFailure }, + }; + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var ctx = new Context(reg.StateId, tcs, _eventAggregator) + var ctx = new Context(reg.StateId, nextStates, tcs, _eventAggregator) { Parameters = parameterStack ?? [], Errors = errorStack ?? [], From 48ed6772e524c30bef9b5f52bc842bb585fbc740 Mon Sep 17 00:00:00 2001 From: Damian Suess Date: Sun, 4 Jan 2026 11:54:58 -0500 Subject: [PATCH 2/3] #76 Adds back the ability to optionally override next state transition. StateMap is maintained in the Context. Updated release notes --- .../BasicStateBenchmarks.cs | 2 +- .../StateTests/CustomStateTests.cs | 110 +++++++++++++++--- .../MsDiTests.cs => StateTests/DiMsTests.cs} | 7 +- .../TestData/ParameterType.cs | 6 + .../TestData/StateEnums.cs | 13 ++- .../TestData/States/CustomBasicStates.cs | 66 ++++++++--- source/Lite.StateMachine/Context.cs | 14 ++- .../Lite.StateMachine.csproj | 14 ++- source/Lite.StateMachine/StateMachine.cs | 29 +++-- source/Lite.StateMachine/StateMap.cs | 15 +++ 10 files changed, 218 insertions(+), 58 deletions(-) rename source/Lite.StateMachine.Tests/{DiTests/MsDiTests.cs => StateTests/DiMsTests.cs} (98%) create mode 100644 source/Lite.StateMachine/StateMap.cs diff --git a/source/Lite.StateMachine.BenchmarkTests/BasicStateBenchmarks.cs b/source/Lite.StateMachine.BenchmarkTests/BasicStateBenchmarks.cs index b08dd47..cec6db3 100644 --- a/source/Lite.StateMachine.BenchmarkTests/BasicStateBenchmarks.cs +++ b/source/Lite.StateMachine.BenchmarkTests/BasicStateBenchmarks.cs @@ -43,7 +43,7 @@ public async Task BasicStatesRunsAsync() } [Benchmark] - public void FlatStateMachineRunsSync() + public void BasicStatesRunsSync() { var maxCounter = CyclesBeforeExit; PropertyBag parameters = new() diff --git a/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs b/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs index a7b3b02..dbb5c1b 100644 --- a/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs +++ b/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs @@ -1,28 +1,40 @@ // Copyright Xeno Innovations, Inc. 2025 // See the LICENSE file in the project root for more information. +using System; using System.Threading.Tasks; using Lite.StateMachine.Tests.TestData; +using Lite.StateMachine.Tests.TestData.Services; using Lite.StateMachine.Tests.TestData.States.CustomBasicStates; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Lite.StateMachine.Tests.StateTests; [TestClass] -public class CustomStateTests +public class CustomStateTests : TestBase { public TestContext TestContext { get; set; } [TestMethod] - public async Task BasicState_Overrides_OnSuccess_SuccessAsync() + public async Task BasicState_Override_Executes_SuccessAsync() { - // Assemble - var counter = 0; - var ctxProperties = new PropertyBag() { { ParameterType.Counter, counter } }; + // Assemble with Dependency Injection + var services = new ServiceCollection() + //// Register Services + .AddLogging(InlineTraceLogger(LogLevel.None)) + .AddSingleton() + .BuildServiceProvider(); - var machine = new StateMachine(); + var msgService = services.GetRequiredService(); + Func factory = t => ActivatorUtilities.CreateInstance(services, t); + + var ctxProperties = new PropertyBag() { { ParameterType.Counter, 0 } }; + + var machine = new StateMachine(factory, null, isContextPersistent: true); machine.RegisterState(CustomBasicStateId.State1, CustomBasicStateId.State2_Dummy); machine.RegisterState(CustomBasicStateId.State2_Dummy, CustomBasicStateId.State3); - machine.RegisterState(CustomBasicStateId.State2_SuccessA, CustomBasicStateId.State3); + machine.RegisterState(CustomBasicStateId.State2_Success, CustomBasicStateId.State3); machine.RegisterState(CustomBasicStateId.State3); // Act - Start your engine! @@ -32,17 +44,46 @@ public async Task BasicState_Overrides_OnSuccess_SuccessAsync() Assert.IsNotNull(machine); Assert.IsNull(machine.Context); - /* - // Ensure all states are registered - var enums = Enum.GetValues() - .Cast(); + Assert.AreEqual(1, msgService.Counter1); + Assert.AreEqual(0, msgService.Counter2, "State2Dummy should never enter"); + Assert.AreEqual(1, msgService.Counter3); + } + + [TestMethod] + public async Task BasicState_Override_SkipsState3_SuccessAsync() + { + // Assemble with Dependency Injection + var services = new ServiceCollection() + //// Register Services + .AddLogging(InlineTraceLogger(LogLevel.None)) + .AddSingleton() + .BuildServiceProvider(); + + var msgService = services.GetRequiredService(); + Func factory = t => ActivatorUtilities.CreateInstance(services, t); + + var ctxProperties = new PropertyBag() + { + { ParameterType.Counter, 0 }, + { ParameterType.TestExitEarly, true }, + }; + + var machine = new StateMachine(factory, null, isContextPersistent: true); + machine.RegisterState(CustomBasicStateId.State1, CustomBasicStateId.State2_Dummy); + machine.RegisterState(CustomBasicStateId.State2_Dummy, CustomBasicStateId.State3); + machine.RegisterState(CustomBasicStateId.State2_Success, CustomBasicStateId.State3); + machine.RegisterState(CustomBasicStateId.State3); - Assert.AreEqual(enums.Count(), machine.States.Count()); - Assert.IsTrue(enums.All(k => machine.States.Contains(k))); + // Act - Start your engine! + await machine.RunAsync(CustomBasicStateId.State1, ctxProperties, cancellationToken: TestContext.CancellationToken); + + // Assert Results + Assert.IsNotNull(machine); + Assert.IsNull(machine.Context); - // Ensure they're registered in order - Assert.IsTrue(enums.SequenceEqual(machine.States), "States should be registered for execution in the same order as the defined enums, StateId 1 => 2 => 3."); - */ + Assert.AreEqual(1, msgService.Counter1); + Assert.AreEqual(0, msgService.Counter2, "State2Dummy should never enter"); + Assert.AreEqual(0, msgService.Counter3, "State3 should never enter "); } [TestMethod] @@ -50,4 +91,41 @@ public async Task BasicState_Overrides_OnSuccess_SuccessAsync() public void BasicState_Overrides_OnSuccessOnError_OnFailure_SuccessAsync() { } + + [TestMethod] + public async Task BasicState_Overrides_ThrowUnregisteredException_Async() + { + // Assemble with Dependency Injection + var services = new ServiceCollection() + //// Register Services + .AddLogging(InlineTraceLogger(LogLevel.None)) + .AddSingleton() + .BuildServiceProvider(); + + var msgService = services.GetRequiredService(); + Func factory = t => ActivatorUtilities.CreateInstance(services, t); + + var ctxProperties = new PropertyBag() + { + { ParameterType.Counter, 0 }, + { ParameterType.TestUnregisteredTransition, true }, + }; + + var machine = new StateMachine(factory, null, isContextPersistent: true); + machine.RegisterState(CustomBasicStateId.State1, CustomBasicStateId.State2_Dummy); + machine.RegisterState(CustomBasicStateId.State2_Dummy, CustomBasicStateId.State3); + machine.RegisterState(CustomBasicStateId.State2_Success, CustomBasicStateId.State3); + machine.RegisterState(CustomBasicStateId.State3); + + // Act - Start your engine! + await Assert.ThrowsExactlyAsync(() + => machine.RunAsync(CustomBasicStateId.State1, ctxProperties, null, TestContext.CancellationToken)); + + // Assert Results + Assert.IsNotNull(machine); + Assert.IsNull(machine.Context); + + Assert.AreEqual(0, msgService.Counter1); + Assert.AreEqual(0, msgService.Counter2, "State2Dummy should never enter"); + } } diff --git a/source/Lite.StateMachine.Tests/DiTests/MsDiTests.cs b/source/Lite.StateMachine.Tests/StateTests/DiMsTests.cs similarity index 98% rename from source/Lite.StateMachine.Tests/DiTests/MsDiTests.cs rename to source/Lite.StateMachine.Tests/StateTests/DiMsTests.cs index c95cdfd..463a7bd 100644 --- a/source/Lite.StateMachine.Tests/DiTests/MsDiTests.cs +++ b/source/Lite.StateMachine.Tests/StateTests/DiMsTests.cs @@ -13,11 +13,12 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Console; -namespace Lite.StateMachine.Tests.DiTests; +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 MsDiTests +public class DiMsTests { [TestMethod] public async Task Basic_FlatStates_SuccessTestAsync() @@ -197,7 +198,7 @@ public async Task RegisterState_MsDi_EventAggregatorOnly_SuccessTestAsync() var aggregator = services.GetRequiredService(); var msgService = services.GetRequiredService(); - var logService = services.GetRequiredService>(); + var logService = services.GetRequiredService>(); msgService.Counter1 = 0; msgService.Counter2 = 0; diff --git a/source/Lite.StateMachine.Tests/TestData/ParameterType.cs b/source/Lite.StateMachine.Tests/TestData/ParameterType.cs index 4c615bf..a6a5a70 100644 --- a/source/Lite.StateMachine.Tests/TestData/ParameterType.cs +++ b/source/Lite.StateMachine.Tests/TestData/ParameterType.cs @@ -13,4 +13,10 @@ public enum ParameterType /// Random test. KeyTest, + + /// Test triggers an early exit. Setting OnSuccess to NULL. + TestExitEarly, + + /// Test trigger to go to an invalid state transition. + TestUnregisteredTransition, } diff --git a/source/Lite.StateMachine.Tests/TestData/StateEnums.cs b/source/Lite.StateMachine.Tests/TestData/StateEnums.cs index e560efd..7f34141 100644 --- a/source/Lite.StateMachine.Tests/TestData/StateEnums.cs +++ b/source/Lite.StateMachine.Tests/TestData/StateEnums.cs @@ -50,12 +50,13 @@ public enum CustomBasicStateId { State1, State2_Dummy, - State2_SuccessA, - State2_SuccessB, - State2_ErrorA, - State2_ErrorB, - State2_FailureA, - State2_FailureB, + State2_Success, + State2_Unregistered, + + ////State2_ErrorA, + ////State2_ErrorB, + ////State2_FailureA, + ////State2_FailureB, State3, } diff --git a/source/Lite.StateMachine.Tests/TestData/States/CustomBasicStates.cs b/source/Lite.StateMachine.Tests/TestData/States/CustomBasicStates.cs index b436383..4f58d5c 100644 --- a/source/Lite.StateMachine.Tests/TestData/States/CustomBasicStates.cs +++ b/source/Lite.StateMachine.Tests/TestData/States/CustomBasicStates.cs @@ -2,6 +2,7 @@ // See the LICENSE file in the project root for more information. using System.Threading.Tasks; +using Lite.StateMachine.Tests.TestData.Services; #pragma warning disable SA1649 // File name should match first type name #pragma warning disable SA1402 // File may only contain a single type @@ -13,35 +14,74 @@ public class State1 : StateBase { public State1() => HasDebugLogging = true; - public override async Task OnEnter(Context context) + public override async Task OnEnter(Context ctx) { - int cnt = context.ParameterAsInt(ParameterType.Counter); + int cnt = ctx.ParameterAsInt(ParameterType.Counter); - context.NextStates[Result.Success] = CustomBasicStateId.State2_SuccessA; + if (ctx.ParameterAsBool(ParameterType.TestUnregisteredTransition)) + ctx.NextStates.OnSuccess = CustomBasicStateId.State2_Unregistered; + else + ctx.NextStates.OnSuccess = CustomBasicStateId.State2_Success; - // vNext: Cycle through each of the OnSuccess/OnExit/OnFailure - ////if (cnt == 0) - //// context.NextStates[Result.Success] = CustomBasicStateId.State2_SuccessA; - ////else if (cnt == 1) - //// context.NextStates[Result.Success] = CustomBasicStateId.State2_SuccessB; - - await base.OnEnter(context); + await base.OnEnter(ctx); } } +/// This state should NEVER be transitioned into. public class State2Dummy : StateBase { - public State2Dummy() => HasDebugLogging = true; + private readonly IMessageService _msgService; + + public State2Dummy(IMessageService msg) + { + _msgService = msg; + HasDebugLogging = true; + } + + public override Task OnEnter(Context context) + { + Assert.Fail("Overridden state transitions should not all us to be here."); + _msgService.Counter2++; + return base.OnEnter(context); + } } public class State2SuccessA : StateBase { - public State2SuccessA() => HasDebugLogging = true; + private readonly IMessageService _msgService; + + public State2SuccessA(IMessageService msg) + { + _msgService = msg; + HasDebugLogging = true; + } + + public override Task OnEnter(Context ctx) + { + _msgService.Counter1++; + + if (ctx.ParameterAsBool(ParameterType.TestExitEarly)) + ctx.NextStates.OnSuccess = null; + + return base.OnEnter(ctx); + } } public class State3 : StateBase { - public State3() => HasDebugLogging = true; + private readonly IMessageService _msgService; + + public State3(IMessageService msg) + { + _msgService = msg; + HasDebugLogging = true; + } + + public override Task OnEnter(Context ctx) + { + _msgService.Counter3++; + return base.OnEnter(ctx); + } } #pragma warning restore IDE0130 // Namespace does not match folder structure diff --git a/source/Lite.StateMachine/Context.cs b/source/Lite.StateMachine/Context.cs index 0c2c314..b55840f 100644 --- a/source/Lite.StateMachine/Context.cs +++ b/source/Lite.StateMachine/Context.cs @@ -4,7 +4,6 @@ namespace Lite.StateMachine; using System; -using System.Collections.Generic; using System.Threading.Tasks; /// Context passed to every state. Provides a "Parameter" and a NextState(Result) trigger. @@ -12,11 +11,19 @@ namespace Lite.StateMachine; public sealed class Context where TStateId : struct, Enum { +#pragma warning disable SA1401 // Fields should be private + + /// Mapping of the next state transitions for this state. + /// Optionally override your next transitions. + public StateMap NextStates; + +#pragma warning restore SA1401 // Fields should be private + private readonly TaskCompletionSource _tcs; internal Context( TStateId current, - Dictionary nextStates, + StateMap nextStates, TaskCompletionSource tcs, IEventAggregator? eventAggregator = null, Result? lastChildResult = null) @@ -43,9 +50,6 @@ internal Context( /////// Gets the previous state's enum value. ////public TStateId PreviousState { get; internal set; } - /// Gets or sets the next transition for previewing and overriding. - public Dictionary NextStates { get; set; } = []; - /// Gets or sets an arbitrary parameter provided by caller to the current action. public PropertyBag Parameters { get; set; } = []; diff --git a/source/Lite.StateMachine/Lite.StateMachine.csproj b/source/Lite.StateMachine/Lite.StateMachine.csproj index 609fd35..e149f26 100644 --- a/source/Lite.StateMachine/Lite.StateMachine.csproj +++ b/source/Lite.StateMachine/Lite.StateMachine.csproj @@ -21,15 +21,17 @@ https://github.com/SuessLabs/Lite.StateMachine statemachine;lite;fsm;suesslabs;xeno-innovations LICENSE.md - New Features in v2.2: + + New Features in v2.2: -* New: Custom override NextState Transition -* New: Renamed, Result.Ok -> Result.Success (Breaking Change) + * New: Renamed, Result.Ok -> Result.Success (Breaking Change). + * New: Renamed, Context.ErrorStaack -> Context.Errors (Breaking Change). + * New: Context.NextStates provides ability to override the next transitions for a state. -New Features in v2.1: + New Features in v2.1: -* Proprety: IsContextPersistent - Auto-cleans Context parameters added by substates. -* Renamed `RegisterCompositeChild(...)` to `RegisterSubComposite(...)` (breaking change) + * Proprety: IsContextPersistent - Auto-cleans Context parameters added by substates. + * Renamed `RegisterCompositeChild(...)` to `RegisterSubComposite(...)` (breaking change) diff --git a/source/Lite.StateMachine/StateMachine.cs b/source/Lite.StateMachine/StateMachine.cs index 681a48f..76cf441 100644 --- a/source/Lite.StateMachine/StateMachine.cs +++ b/source/Lite.StateMachine/StateMachine.cs @@ -301,11 +301,11 @@ private StateRegistration GetRegistration(TStateId stateId) // Composite States var instance = GetOrCreateInstance(reg); - Dictionary nextStates = new() + StateMap nextStates = new() { - { Result.Success, reg.OnSuccess }, - { Result.Error, reg.OnError }, - { Result.Failure, reg.OnFailure }, + OnSuccess = reg.OnSuccess, + OnError = reg.OnError, + OnFailure = reg.OnFailure, }; var parentEnterTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -328,6 +328,11 @@ private StateRegistration GetRegistration(TStateId stateId) await instance.OnEnter(parentEnterCtx).ConfigureAwait(false); + // Check for next transition overrides + ////reg.OnSuccess = parentEnterCtx.NextStates.OnSuccess; + ////reg.OnError = parentEnterCtx.NextStates.OnError; + ////reg.OnFailure = parentEnterCtx.NextStates.OnFailure; + // TODO (2025-12-28 DS): Consider StateMachine config param to just move along or throw exception if (reg.InitialChildId is null) throw new MissingInitialSubStateException($"Composite '{reg.StateId}' must have an initial child (InitialChildId)."); @@ -419,11 +424,11 @@ private StateRegistration GetRegistration(TStateId stateId) IState instance = GetOrCreateInstance(reg); // Next state transitions - Dictionary nextStates = new() + StateMap nextStates = new() { - { Result.Success, reg.OnSuccess }, - { Result.Error, reg.OnError }, - { Result.Failure, reg.OnFailure }, + OnSuccess = reg.OnSuccess, + OnError = reg.OnError, + OnFailure = reg.OnFailure, }; var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -460,7 +465,9 @@ private StateRegistration GetRegistration(TStateId stateId) { await Task.Delay(timeoutMs, timeoutCts.Token).ConfigureAwait(false); if (!tcs.Task.IsCompleted && !timeoutCts.IsCancellationRequested) + { await cmd.OnTimeout(ctx).ConfigureAwait(false); + } } catch (TaskCanceledException) { @@ -480,10 +487,16 @@ private StateRegistration GetRegistration(TStateId stateId) var result = await WaitForNextOrCancelAsync(tcs.Task, cancellationToken).ConfigureAwait(false); // TODO (2025-12-28 DS): Potential DefaultStateTimeoutMs. Even leaving OnEnter without NextState(Result.OK), should consider calling `OnExit` to allow states to cleanup. + // TODO (2026-01-04 DS): Consider handling "OnTRANSITION" overrides. Passing a single Context would solve this as it's passed by ref. if (result is null) return null; await instance.OnExit(ctx).ConfigureAwait(false); + + reg.OnSuccess = ctx.NextStates.OnSuccess; + reg.OnError = ctx.NextStates.OnError; + reg.OnFailure = ctx.NextStates.OnFailure; + return result.Value; } finally diff --git a/source/Lite.StateMachine/StateMap.cs b/source/Lite.StateMachine/StateMap.cs new file mode 100644 index 0000000..2fefd3c --- /dev/null +++ b/source/Lite.StateMachine/StateMap.cs @@ -0,0 +1,15 @@ +// Copyright Xeno Innovations, Inc. 2025 +// See the LICENSE file in the project root for more information. + +// This is here so CodeMaid doesn't reorganize this document. +using System; + +namespace Lite.StateMachine; + +public struct StateMap + where TStateId : struct, Enum +{ + public TStateId? OnSuccess; + public TStateId? OnError; + public TStateId? OnFailure; +} From 7606070d1436b3792485a8305af61ead93df1ab2 Mon Sep 17 00:00:00 2001 From: Damian Suess Date: Mon, 5 Jan 2026 08:10:34 -0500 Subject: [PATCH 3/3] Tests for overriding substate next state transition and moving forward --- .../StateTests/CustomStateTests.cs | 80 ++++++++++--------- .../TestData/ParameterType.cs | 3 + .../TestData/StateEnums.cs | 16 ++-- .../TestData/States/CustomBasicStates.cs | 74 ++++++++++++++--- source/Lite.StateMachine/Context.cs | 1 + 5 files changed, 120 insertions(+), 54 deletions(-) diff --git a/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs b/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs index dbb5c1b..67080d3 100644 --- a/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs +++ b/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs @@ -5,7 +5,7 @@ using System.Threading.Tasks; using Lite.StateMachine.Tests.TestData; using Lite.StateMachine.Tests.TestData.Services; -using Lite.StateMachine.Tests.TestData.States.CustomBasicStates; +using Lite.StateMachine.Tests.TestData.States.CustomStates; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -17,7 +17,9 @@ public class CustomStateTests : TestBase public TestContext TestContext { get; set; } [TestMethod] - public async Task BasicState_Override_Executes_SuccessAsync() + [DataRow(false, DisplayName = "Don't skip State3")] + [DataRow(true, DisplayName = "Skip State3")] + public async Task BasicState_Override_Executes_SuccessAsync(bool skipState3) { // Assemble with Dependency Injection var services = new ServiceCollection() @@ -29,16 +31,20 @@ public async Task BasicState_Override_Executes_SuccessAsync() var msgService = services.GetRequiredService(); Func factory = t => ActivatorUtilities.CreateInstance(services, t); - var ctxProperties = new PropertyBag() { { ParameterType.Counter, 0 } }; + var ctxProperties = new PropertyBag() + { + { ParameterType.Counter, 0 }, + { ParameterType.TestExitEarly, skipState3 }, + }; - var machine = new StateMachine(factory, null, isContextPersistent: true); - machine.RegisterState(CustomBasicStateId.State1, CustomBasicStateId.State2_Dummy); - machine.RegisterState(CustomBasicStateId.State2_Dummy, CustomBasicStateId.State3); - machine.RegisterState(CustomBasicStateId.State2_Success, CustomBasicStateId.State3); - machine.RegisterState(CustomBasicStateId.State3); + var machine = new StateMachine(factory, null, isContextPersistent: true); + machine.RegisterState(CustomStateId.State1, CustomStateId.State2_Dummy); + machine.RegisterState(CustomStateId.State2_Dummy, CustomStateId.State3); + machine.RegisterState(CustomStateId.State2_Success, CustomStateId.State3); + machine.RegisterState(CustomStateId.State3); // Act - Start your engine! - await machine.RunAsync(CustomBasicStateId.State1, ctxProperties, cancellationToken: TestContext.CancellationToken); + await machine.RunAsync(CustomStateId.State1, ctxProperties, cancellationToken: TestContext.CancellationToken); // Assert Results Assert.IsNotNull(machine); @@ -46,11 +52,11 @@ public async Task BasicState_Override_Executes_SuccessAsync() Assert.AreEqual(1, msgService.Counter1); Assert.AreEqual(0, msgService.Counter2, "State2Dummy should never enter"); - Assert.AreEqual(1, msgService.Counter3); + Assert.AreEqual(skipState3 ? 0 : 1, msgService.Counter3); } [TestMethod] - public async Task BasicState_Override_SkipsState3_SuccessAsync() + public async Task BasicState_Overrides_ThrowUnregisteredException_Async() { // Assemble with Dependency Injection var services = new ServiceCollection() @@ -65,40 +71,36 @@ public async Task BasicState_Override_SkipsState3_SuccessAsync() var ctxProperties = new PropertyBag() { { ParameterType.Counter, 0 }, - { ParameterType.TestExitEarly, true }, + { ParameterType.TestUnregisteredTransition, true }, }; - var machine = new StateMachine(factory, null, isContextPersistent: true); - machine.RegisterState(CustomBasicStateId.State1, CustomBasicStateId.State2_Dummy); - machine.RegisterState(CustomBasicStateId.State2_Dummy, CustomBasicStateId.State3); - machine.RegisterState(CustomBasicStateId.State2_Success, CustomBasicStateId.State3); - machine.RegisterState(CustomBasicStateId.State3); + var machine = new StateMachine(factory, null, isContextPersistent: true); + machine.RegisterState(CustomStateId.State1, CustomStateId.State2_Dummy); + machine.RegisterState(CustomStateId.State2_Dummy, CustomStateId.State3); + machine.RegisterState(CustomStateId.State2_Success, CustomStateId.State3); + machine.RegisterState(CustomStateId.State3); // Act - Start your engine! - await machine.RunAsync(CustomBasicStateId.State1, ctxProperties, cancellationToken: TestContext.CancellationToken); + await Assert.ThrowsExactlyAsync(() + => machine.RunAsync(CustomStateId.State1, ctxProperties, null, TestContext.CancellationToken)); // Assert Results Assert.IsNotNull(machine); Assert.IsNull(machine.Context); - Assert.AreEqual(1, msgService.Counter1); + Assert.AreEqual(0, msgService.Counter1); Assert.AreEqual(0, msgService.Counter2, "State2Dummy should never enter"); - Assert.AreEqual(0, msgService.Counter3, "State3 should never enter "); } [TestMethod] - [Ignore] - public void BasicState_Overrides_OnSuccessOnError_OnFailure_SuccessAsync() - { - } - - [TestMethod] - public async Task BasicState_Overrides_ThrowUnregisteredException_Async() + [DataRow(false, DisplayName = "Run State2_Sub3")] + [DataRow(true, DisplayName = "Skip State2_Sub3")] + public async Task Composite_Override_Executes_SuccessAsync(bool skipSubState3) { // Assemble with Dependency Injection var services = new ServiceCollection() //// Register Services - .AddLogging(InlineTraceLogger(LogLevel.None)) + .AddLogging(InlineTraceLogger(LogLevel.Trace)) .AddSingleton() .BuildServiceProvider(); @@ -108,24 +110,28 @@ public async Task BasicState_Overrides_ThrowUnregisteredException_Async() var ctxProperties = new PropertyBag() { { ParameterType.Counter, 0 }, - { ParameterType.TestUnregisteredTransition, true }, + { ParameterType.TestExitEarly2, skipSubState3 }, }; - var machine = new StateMachine(factory, null, isContextPersistent: true); - machine.RegisterState(CustomBasicStateId.State1, CustomBasicStateId.State2_Dummy); - machine.RegisterState(CustomBasicStateId.State2_Dummy, CustomBasicStateId.State3); - machine.RegisterState(CustomBasicStateId.State2_Success, CustomBasicStateId.State3); - machine.RegisterState(CustomBasicStateId.State3); + var machine = new StateMachine(factory, null, isContextPersistent: true); + + machine.RegisterState(CustomStateId.State1, CustomStateId.State2_Dummy); + machine.RegisterState(CustomStateId.State2_Dummy, CustomStateId.State3); + machine.RegisterComposite(CustomStateId.State2_Success, CustomStateId.State2_Sub1, CustomStateId.State3); + machine.RegisterSubState(CustomStateId.State2_Sub1, CustomStateId.State2_Success, CustomStateId.State2_Sub2); + machine.RegisterSubState(CustomStateId.State2_Sub2, CustomStateId.State2_Success, CustomStateId.State2_Sub3); + machine.RegisterSubState(CustomStateId.State2_Sub3, CustomStateId.State2_Success); + machine.RegisterState(CustomStateId.State3); // Act - Start your engine! - await Assert.ThrowsExactlyAsync(() - => machine.RunAsync(CustomBasicStateId.State1, ctxProperties, null, TestContext.CancellationToken)); + await machine.RunAsync(CustomStateId.State1, ctxProperties, cancellationToken: TestContext.CancellationToken); // Assert Results Assert.IsNotNull(machine); Assert.IsNull(machine.Context); - Assert.AreEqual(0, msgService.Counter1); + Assert.AreEqual(skipSubState3 ? 2 : 3, 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 a6a5a70..1083087 100644 --- a/source/Lite.StateMachine.Tests/TestData/ParameterType.cs +++ b/source/Lite.StateMachine.Tests/TestData/ParameterType.cs @@ -17,6 +17,9 @@ public enum ParameterType /// Test triggers an early exit. Setting OnSuccess to NULL. TestExitEarly, + /// Test triggers a 2nd early exit. Setting OnSuccess to NULL. + TestExitEarly2, + /// Test trigger to go to an invalid state transition. TestUnregisteredTransition, } diff --git a/source/Lite.StateMachine.Tests/TestData/StateEnums.cs b/source/Lite.StateMachine.Tests/TestData/StateEnums.cs index 7f34141..a69907a 100644 --- a/source/Lite.StateMachine.Tests/TestData/StateEnums.cs +++ b/source/Lite.StateMachine.Tests/TestData/StateEnums.cs @@ -46,17 +46,23 @@ public enum CompositeMsgStateId Failure, } -public enum CustomBasicStateId +public enum CustomStateId { + /// Initial state. State1, + + /// Dummy state that is registered but never transitioned to. State2_Dummy, + State2_Success, + + /// This state must NEVER be registered. State2_Unregistered, - ////State2_ErrorA, - ////State2_ErrorB, - ////State2_FailureA, - ////State2_FailureB, + State2_Sub1, + State2_Sub2, + State2_Sub3, + State3, } diff --git a/source/Lite.StateMachine.Tests/TestData/States/CustomBasicStates.cs b/source/Lite.StateMachine.Tests/TestData/States/CustomBasicStates.cs index 4f58d5c..9a4338e 100644 --- a/source/Lite.StateMachine.Tests/TestData/States/CustomBasicStates.cs +++ b/source/Lite.StateMachine.Tests/TestData/States/CustomBasicStates.cs @@ -8,27 +8,27 @@ #pragma warning disable SA1402 // File may only contain a single type #pragma warning disable IDE0130 // Namespace does not match folder structure -namespace Lite.StateMachine.Tests.TestData.States.CustomBasicStates; +namespace Lite.StateMachine.Tests.TestData.States.CustomStates; -public class State1 : StateBase +public class State1 : StateBase { public State1() => HasDebugLogging = true; - public override async Task OnEnter(Context ctx) + public override async Task OnEnter(Context ctx) { int cnt = ctx.ParameterAsInt(ParameterType.Counter); if (ctx.ParameterAsBool(ParameterType.TestUnregisteredTransition)) - ctx.NextStates.OnSuccess = CustomBasicStateId.State2_Unregistered; + ctx.NextStates.OnSuccess = CustomStateId.State2_Unregistered; else - ctx.NextStates.OnSuccess = CustomBasicStateId.State2_Success; + ctx.NextStates.OnSuccess = CustomStateId.State2_Success; await base.OnEnter(ctx); } } /// This state should NEVER be transitioned into. -public class State2Dummy : StateBase +public class State2Dummy : StateBase { private readonly IMessageService _msgService; @@ -38,7 +38,7 @@ public State2Dummy(IMessageService msg) HasDebugLogging = true; } - public override Task OnEnter(Context context) + public override Task OnEnter(Context context) { Assert.Fail("Overridden state transitions should not all us to be here."); _msgService.Counter2++; @@ -46,17 +46,17 @@ public override Task OnEnter(Context context) } } -public class State2SuccessA : StateBase +public class State2Success : StateBase { private readonly IMessageService _msgService; - public State2SuccessA(IMessageService msg) + public State2Success(IMessageService msg) { _msgService = msg; HasDebugLogging = true; } - public override Task OnEnter(Context ctx) + public override Task OnEnter(Context ctx) { _msgService.Counter1++; @@ -65,9 +65,59 @@ public override Task OnEnter(Context ctx) return base.OnEnter(ctx); } + + public override Task OnExit(Context context) + { + // When operating as a Composite, we need to pass back Success. + context.NextState(Result.Success); + return base.OnExit(context); + } +} + +public class State2Success_Sub1 : StateBase +{ + public State2Success_Sub1() => HasDebugLogging = true; +} + +public class State2Success_Sub2 : StateBase +{ + private readonly IMessageService _msgService; + + public State2Success_Sub2(IMessageService msg) + { + _msgService = msg; + HasDebugLogging = true; + } + + public override Task OnEnter(Context ctx) + { + _msgService.Counter1++; + + if (ctx.ParameterAsBool(ParameterType.TestExitEarly2)) + ctx.NextStates.OnSuccess = null; + + return base.OnEnter(ctx); + } +} + +public class State2Success_Sub3 : StateBase +{ + private readonly IMessageService _msgService; + + public State2Success_Sub3(IMessageService msg) + { + _msgService = msg; + HasDebugLogging = true; + } + + public override Task OnEnter(Context ctx) + { + _msgService.Counter1++; + return base.OnEnter(ctx); + } } -public class State3 : StateBase +public class State3 : StateBase { private readonly IMessageService _msgService; @@ -77,7 +127,7 @@ public State3(IMessageService msg) HasDebugLogging = true; } - public override Task OnEnter(Context ctx) + public override Task OnEnter(Context ctx) { _msgService.Counter3++; return base.OnEnter(ctx); diff --git a/source/Lite.StateMachine/Context.cs b/source/Lite.StateMachine/Context.cs index b55840f..6ad01c6 100644 --- a/source/Lite.StateMachine/Context.cs +++ b/source/Lite.StateMachine/Context.cs @@ -55,6 +55,7 @@ internal Context( /// 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. public void NextState(Result result) => _tcs.TrySetResult(result); public bool ParameterAsBool(object key, bool defaultBool = false)