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 new file mode 100644 index 0000000..67080d3 --- /dev/null +++ b/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs @@ -0,0 +1,137 @@ +// 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.CustomStates; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Lite.StateMachine.Tests.StateTests; + +[TestClass] +public class CustomStateTests : TestBase +{ + public TestContext TestContext { get; set; } + + [TestMethod] + [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() + //// 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, skipState3 }, + }; + + 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(CustomStateId.State1, ctxProperties, cancellationToken: TestContext.CancellationToken); + + // Assert Results + Assert.IsNotNull(machine); + Assert.IsNull(machine.Context); + + Assert.AreEqual(1, msgService.Counter1); + Assert.AreEqual(0, msgService.Counter2, "State2Dummy should never enter"); + Assert.AreEqual(skipState3 ? 0 : 1, msgService.Counter3); + } + + [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(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 Assert.ThrowsExactlyAsync(() + => machine.RunAsync(CustomStateId.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"); + } + + [TestMethod] + [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.Trace)) + .AddSingleton() + .BuildServiceProvider(); + + var msgService = services.GetRequiredService(); + Func factory = t => ActivatorUtilities.CreateInstance(services, t); + + var ctxProperties = new PropertyBag() + { + { ParameterType.Counter, 0 }, + { ParameterType.TestExitEarly2, skipSubState3 }, + }; + + 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 machine.RunAsync(CustomStateId.State1, ctxProperties, cancellationToken: TestContext.CancellationToken); + + // Assert Results + Assert.IsNotNull(machine); + Assert.IsNull(machine.Context); + + 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/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 735ec89..1083087 100644 --- a/source/Lite.StateMachine.Tests/TestData/ParameterType.cs +++ b/source/Lite.StateMachine.Tests/TestData/ParameterType.cs @@ -3,9 +3,23 @@ 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, + + /// 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/States/StateEnums.cs b/source/Lite.StateMachine.Tests/TestData/StateEnums.cs similarity index 65% rename from source/Lite.StateMachine.Tests/TestData/States/StateEnums.cs rename to source/Lite.StateMachine.Tests/TestData/StateEnums.cs index 2c50f98..a69907a 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,24 @@ public enum CompositeMsgStateId Failure, } +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_Sub1, + State2_Sub2, + State2_Sub3, + + 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..9a4338e --- /dev/null +++ b/source/Lite.StateMachine.Tests/TestData/States/CustomBasicStates.cs @@ -0,0 +1,139 @@ +// 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.Services; + +#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.CustomStates; + +public class State1 : StateBase +{ + public State1() => HasDebugLogging = true; + + 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 + ctx.NextStates.OnSuccess = CustomStateId.State2_Success; + + await base.OnEnter(ctx); + } +} + +/// This state should NEVER be transitioned into. +public class State2Dummy : StateBase +{ + 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 State2Success : StateBase +{ + private readonly IMessageService _msgService; + + public State2Success(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 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 +{ + 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 +#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..6ad01c6 100644 --- a/source/Lite.StateMachine/Context.cs +++ b/source/Lite.StateMachine/Context.cs @@ -11,15 +11,25 @@ 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, + StateMap nextStates, TaskCompletionSource tcs, IEventAggregator? eventAggregator = null, Result? lastChildResult = null) { CurrentStateId = current; + NextStates = nextStates; _tcs = tcs; EventAggregator = eventAggregator; LastChildResult = lastChildResult; @@ -38,23 +48,16 @@ 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 an arbitrary parameter provided by caller to the current action. public PropertyBag Parameters { get; 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. 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..e149f26 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,17 @@ https://github.com/SuessLabs/Lite.StateMachine statemachine;lite;fsm;suesslabs;xeno-innovations LICENSE.md - New features in v2.1: + + New Features in v2.2: -* Proprety: IsContextPersistent - Auto-cleans Context parameters added by substates. -* Renamed `RegisterCompositeChild(...)` to `RegisterSubComposite(...)` + * 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: + + * 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 f4eaf5a..76cf441 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); + StateMap nextStates = new() + { + OnSuccess = reg.OnSuccess, + OnError = reg.OnError, + OnFailure = 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, @@ -321,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)."); @@ -348,6 +360,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 +380,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 +422,17 @@ private StateRegistration GetRegistration(TStateId stateId) CancellationToken cancellationToken) { IState instance = GetOrCreateInstance(reg); + + // Next state transitions + StateMap nextStates = new() + { + OnSuccess = reg.OnSuccess, + OnError = reg.OnError, + OnFailure = 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 ?? [], @@ -443,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) { @@ -463,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; +}