diff --git a/source/Lite.StateMachine.Tests/StateTests/CommandStateTests.cs b/source/Lite.StateMachine.Tests/StateTests/CommandStateTests.cs index 8363e69..09c3eb0 100644 --- a/source/Lite.StateMachine.Tests/StateTests/CommandStateTests.cs +++ b/source/Lite.StateMachine.Tests/StateTests/CommandStateTests.cs @@ -47,11 +47,11 @@ public async Task BasicState_Override_Executes_SuccessAsync() machine .AddContext(ctxProperties) - .RegisterState(StateId.State1, StateId.State2) + .RegisterState(StateId.State1, StateId.State2, subscriptionTypes: [typeof(UnlockResponse)]) .RegisterComposite(StateId.State2, initialChildStateId: StateId.State2_Sub1, onSuccess: StateId.State3) .RegisterSubState(StateId.State2_Sub1, parentStateId: StateId.State2, onSuccess: StateId.State2_Sub2) .RegisterSubComposite(StateId.State2_Sub2, parentStateId: StateId.State2, initialChildStateId: StateId.State2_Sub2_Sub1, onSuccess: StateId.State2_Sub3) - .RegisterSubState(StateId.State2_Sub2_Sub1, parentStateId: StateId.State2_Sub2, onSuccess: StateId.State2_Sub2_Sub2) + .RegisterSubState(StateId.State2_Sub2_Sub1, parentStateId: StateId.State2_Sub2, onSuccess: StateId.State2_Sub2_Sub2, subscriptionTypes: [typeof(UnlockResponse), typeof(CloseResponse)]) .RegisterSubState(StateId.State2_Sub2_Sub2, parentStateId: StateId.State2_Sub2, onSuccess: StateId.State2_Sub2_Sub3) .RegisterSubState(StateId.State2_Sub2_Sub3, parentStateId: StateId.State2_Sub2, onSuccess: null) .RegisterSubState(StateId.State2_Sub3, parentStateId: StateId.State2, onSuccess: null) @@ -140,7 +140,11 @@ public async Task CancelsInfiniteStateMachineTestAsync() Assert.AreEqual(100, counter); } +#pragma warning disable SA1124 // Do not use regions + #region Infinite Loop Test State Classes + private class InfState1 : IState +#pragma warning restore SA1124 // Do not use regions { public Task OnEnter(Context context) { @@ -178,4 +182,6 @@ public Task OnMessage(Context context, object message) public Task OnTimeout(Context context) => Task.CompletedTask; } + + #endregion Infinite Loop Test State Classes } diff --git a/source/Lite.StateMachine.Tests/TestData/Services/MessageService.cs b/source/Lite.StateMachine.Tests/TestData/Services/MessageService.cs index d233e14..876cbfc 100644 --- a/source/Lite.StateMachine.Tests/TestData/Services/MessageService.cs +++ b/source/Lite.StateMachine.Tests/TestData/Services/MessageService.cs @@ -11,7 +11,7 @@ public interface IMessageService { /// uses it as an automatic state transition counter. + /// uses it as an automatic state transition counter. /// int Counter1 { get; set; } diff --git a/source/Lite.StateMachine.Tests/TestData/States/BasicDiStates.cs b/source/Lite.StateMachine.Tests/TestData/States/BasicDiStates.cs index d289977..485f9e1 100644 --- a/source/Lite.StateMachine.Tests/TestData/States/BasicDiStates.cs +++ b/source/Lite.StateMachine.Tests/TestData/States/BasicDiStates.cs @@ -10,13 +10,13 @@ namespace Lite.StateMachine.Tests.TestData.States; #pragma warning disable SA1402 // File may only contain a single type public class BasicDiState1(IMessageService msg, ILogger log) - : DiStateBase(msg, log); + : StateDiBase(msg, log); public class BasicDiState2(IMessageService msg, ILogger log) - : DiStateBase(msg, log); + : StateDiBase(msg, log); public class BasicDiState3(IMessageService msg, ILogger log) - : DiStateBase(msg, log); + : StateDiBase(msg, log); #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/CommandL3States.cs b/source/Lite.StateMachine.Tests/TestData/States/CommandL3States.cs index cf87709..98d07a5 100644 --- a/source/Lite.StateMachine.Tests/TestData/States/CommandL3States.cs +++ b/source/Lite.StateMachine.Tests/TestData/States/CommandL3States.cs @@ -31,24 +31,14 @@ public enum StateId Error, } -public class CommonDiStateBase(IMessageService msg, ILogger logger) - : DiStateBase(msg, logger) - where TStateId : struct, Enum -{ - // Helper so we don't have to keep rewriting the same "override Task OnEnter(...)" - // 8 lines * 9 states.. useless - public override Task OnEnter(Context context) - { - context.Parameters.Add(context.CurrentStateId.ToString(), Guid.NewGuid()); - MessageService.AddMessage($"[Keys-{context.CurrentStateId}]: {string.Join(",", context.Parameters.Keys)}"); - return base.OnEnter(context); - } -} - public class State1(IMessageService msg, ILogger log) : CommandStateBase(msg, log) { /// Gets message types for command state to subscribe to. + /// + /// Already subscribed to in StateMachine builder. Defining twice to test that we don't + /// get duplicate messages. + /// public override IReadOnlyCollection SubscribedMessageTypes => new[] { //// typeof(OpenCommand), // <---- NOTE: Not needed @@ -96,7 +86,7 @@ public override Task OnTimeout(Context context) /// Level-1: Composite. public class State2(IMessageService msg, ILogger log) - : CommonDiStateBase(msg, log) + : StateDiMessageBase(msg, log) { #region CodeMaid - Suppress method sorting @@ -127,14 +117,14 @@ public override Task OnExit(Context context) /// Sublevel-2: State. public class State2_Sub1(IMessageService msg, ILogger log) - : CommonDiStateBase(msg, log) + : StateDiMessageBase(msg, log) { public override Task OnEnter(Context context) => base.OnEnter(context); } /// Sublevel-2: Composite. public class State2_Sub2(IMessageService msg, ILogger log) - : CommonDiStateBase(msg, log) + : StateDiMessageBase(msg, log) { #region CodeMaid - DoNotReorder @@ -173,7 +163,7 @@ public override Task OnExit(Context context) /// Sublevel-3: State. public class State2_Sub2_Sub1(IMessageService msg, ILogger log) - : CommonDiStateBase(msg, log) + : StateDiMessageBase(msg, log) { public override Task OnEnter(Context context) => base.OnEnter(context); } @@ -182,12 +172,13 @@ public class State2_Sub2_Sub1(IMessageService msg, ILogger log public class State2_Sub2_Sub2(IMessageService msg, ILogger log) : CommandStateBase(msg, log) { - /// Gets message types for command state to subscribe to. - public override IReadOnlyCollection SubscribedMessageTypes => - [ - typeof(UnlockResponse), - typeof(CloseResponse), - ]; + // Already subscribed to in StateMachine builder + /////// Gets message types for command state to subscribe to. + ////public override IReadOnlyCollection SubscribedMessageTypes => + ////[ + //// typeof(UnlockResponse), + //// typeof(CloseResponse), + ////]; public override Task OnEnter(Context context) { @@ -224,14 +215,14 @@ public override Task OnTimeout(Context context) /// Sublevel-3: Last State. public class State2_Sub2_Sub3(IMessageService msg, ILogger log) -: CommonDiStateBase(msg, log) +: StateDiMessageBase(msg, log) { public override Task OnEnter(Context context) => base.OnEnter(context); } /// Sublevel-2: Last State. public class State2_Sub3(IMessageService msg, ILogger log) - : DiStateBase(msg, log) + : StateDiBase(msg, log) { public override Task OnEnter(Context context) { @@ -250,7 +241,7 @@ public override Task OnEnter(Context context) /// Make sure not child-created context is there. public class State3(IMessageService msg, ILogger log) - : DiStateBase(msg, log) + : StateDiBase(msg, log) { public override Task OnEnter(Context context) { @@ -263,6 +254,20 @@ public override Task OnEnter(Context context) } } +public class StateDiMessageBase(IMessageService msg, ILogger logger) + : StateDiBase(msg, logger) + where TStateId : struct, Enum +{ + // Helper so we don't have to keep rewriting the same "override Task OnEnter(...)" + // 8 lines * 9 states.. useless + public override Task OnEnter(Context context) + { + context.Parameters.Add(context.CurrentStateId.ToString(), Guid.NewGuid()); + MessageService.AddMessage($"[Keys-{context.CurrentStateId}]: {string.Join(",", context.Parameters.Keys)}"); + return base.OnEnter(context); + } +} + #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/CommandStateBase.cs b/source/Lite.StateMachine.Tests/TestData/States/CommandStateBase.cs index d0256f0..679ae99 100644 --- a/source/Lite.StateMachine.Tests/TestData/States/CommandStateBase.cs +++ b/source/Lite.StateMachine.Tests/TestData/States/CommandStateBase.cs @@ -14,7 +14,7 @@ namespace Lite.StateMachine.Tests.TestData.States; /// State class object. /// State Id. public class CommandStateBase(IMessageService msg, ILogger logger) - : DiStateBase(msg, logger), ICommandState + : StateDiBase(msg, logger), ICommandState where TStateId : struct, Enum { //// NEEDS TESTED: public virtual IReadOnlyCollection SubscribedMessageTypes => []; diff --git a/source/Lite.StateMachine.Tests/TestData/States/CompositeL1DiStates.cs b/source/Lite.StateMachine.Tests/TestData/States/CompositeL1DiStates.cs index a886056..6c1d44f 100644 --- a/source/Lite.StateMachine.Tests/TestData/States/CompositeL1DiStates.cs +++ b/source/Lite.StateMachine.Tests/TestData/States/CompositeL1DiStates.cs @@ -12,12 +12,12 @@ namespace Lite.StateMachine.Tests.TestData.States; #pragma warning disable SA1402 // File may only contain a single type public class EntryState(IMessageService msg, ILogger log) - : DiStateBase(msg, log) + : StateDiBase(msg, log) { } public class ParentState(IMessageService msg, ILogger log) - : DiStateBase(msg, log) + : StateDiBase(msg, log) { /// Handle the result from our last child state. /// Context data. @@ -40,12 +40,12 @@ public override Task OnExit(Context context) } public class ParentSub_FetchState(IMessageService msg, ILogger log) - : DiStateBase(msg, log) + : StateDiBase(msg, log) { } public class ParentSub_WaitMessageState(IMessageService msg, ILogger log) - : DiStateBase(msg, log), + : StateDiBase(msg, log), ICommandState { public override Task OnEnter(Context context) @@ -134,12 +134,12 @@ public Task OnTimeout(Context context) } public class Workflow_DoneState(IMessageService msg, ILogger log) - : DiStateBase(msg, log) + : StateDiBase(msg, log) { } public class Workflow_ErrorState(IMessageService msg, ILogger log) - : DiStateBase(msg, log) + : StateDiBase(msg, log) { public override Task OnEnter(Context context) { @@ -158,7 +158,7 @@ public override Task OnEnter(Context context) } public class Workflow_FailureState(IMessageService msg, ILogger log) - : DiStateBase(msg, log) + : StateDiBase(msg, log) { public override Task OnEnter(Context context) { diff --git a/source/Lite.StateMachine.Tests/TestData/States/CompositeL3DiStates.cs b/source/Lite.StateMachine.Tests/TestData/States/CompositeL3DiStates.cs index b0fbb74..c6321f3 100644 --- a/source/Lite.StateMachine.Tests/TestData/States/CompositeL3DiStates.cs +++ b/source/Lite.StateMachine.Tests/TestData/States/CompositeL3DiStates.cs @@ -15,7 +15,7 @@ namespace Lite.StateMachine.Tests.TestData.States.CompositeL3DiStates; public class CommonDiStateBase(IMessageService msg, ILogger logger) - : DiStateBase(msg, logger) + : StateDiBase(msg, logger) where TStateId : struct, Enum { // Helper so we don't have to keep rewriting the same "override Task OnEnter(...)" @@ -29,7 +29,7 @@ public override Task OnEnter(Context context) } public class State1(IMessageService msg, ILogger log) - : DiStateBase(msg, log) + : StateDiBase(msg, log) { public override Task OnEnter(Context context) { @@ -154,7 +154,7 @@ public override Task OnEnter(Context context) /// Sublevel-2: Last State. public class State2_Sub3(IMessageService msg, ILogger log) - : DiStateBase(msg, log) + : StateDiBase(msg, log) { public override Task OnEnter(Context context) { @@ -169,7 +169,7 @@ public override Task OnEnter(Context context) /// Make sure not child-created context is there. public class State3(IMessageService msg, ILogger log) - : DiStateBase(msg, log) + : StateDiBase(msg, log) { public override Task OnEnter(Context context) { diff --git a/source/Lite.StateMachine.Tests/TestData/States/DiStateBase.cs b/source/Lite.StateMachine.Tests/TestData/States/StateDiBase.cs similarity index 96% rename from source/Lite.StateMachine.Tests/TestData/States/DiStateBase.cs rename to source/Lite.StateMachine.Tests/TestData/States/StateDiBase.cs index 0b06b65..8c5b51d 100644 --- a/source/Lite.StateMachine.Tests/TestData/States/DiStateBase.cs +++ b/source/Lite.StateMachine.Tests/TestData/States/StateDiBase.cs @@ -11,7 +11,7 @@ namespace Lite.StateMachine.Tests.TestData.States; #pragma warning disable SA1124 // Do not use regions -public class DiStateBase(IMessageService msg, ILogger logger) : IState +public class StateDiBase(IMessageService msg, ILogger logger) : IState where TStateId : struct, Enum { private readonly ILogger _logger = logger; diff --git a/source/Lite.StateMachine/IStateMachine.cs b/source/Lite.StateMachine/IStateMachine.cs index d8d000d..f9362d0 100644 --- a/source/Lite.StateMachine/IStateMachine.cs +++ b/source/Lite.StateMachine/IStateMachine.cs @@ -24,6 +24,9 @@ public interface IStateMachine /// Gets or sets the default timeout in milliseconds (ms default). Set timeout to ensure no stuck states (i.e., robotics). int DefaultStateTimeoutMs { get; set; } + /// Gets or sets a value indicating whether substate-added context persists when returning to the parent (default: true). + bool IsContextPersistent { get; set; } + /// Gets the collection of all registered states. /// /// Exposed for validations, debugging, etc. @@ -31,6 +34,12 @@ public interface IStateMachine /// List States { get; } + /// Preload properties and errors to the context. + /// Parameter properties to safely add/update. + /// Error properties to safely add/update. + /// Instance of this class. + StateMachine AddContext(PropertyBag? parameters = null, PropertyBag? errors = null); + /// /// Registers a top-level composite parent state (has no parent state) and explicitly sets: /// - the initial child (initialChildStateId). @@ -63,10 +72,11 @@ StateMachine RegisterSubComposite(TStateId stateId, /// State Id to transition to on success, or null to denote last state and exit . /// State Id to transition to on error, or null to denote last state and exit . /// State Id to transition to on failure, or null to denote last state and exit . + /// Optional subscription message types. /// Instance of this class. /// State class. /// Example: (StateId.State1, StateId.State2);]]>. - StateMachine RegisterState(TStateId stateId, TStateId? onSuccess, TStateId? onError, TStateId? onFailure) + StateMachine RegisterState(TStateId stateId, TStateId? onSuccess, TStateId? onError, TStateId? onFailure, IReadOnlyCollection? commandSubscriptionTypes = null) where TState : class, IState; /// @@ -79,6 +89,7 @@ StateMachine RegisterState(TStateId stateId, TStateId? onSucce /// The identifier of the parent state if the registered state is part of a composite state; otherwise, null. /// true if the registered state is a composite parent state; otherwise, false. /// The identifier of the initial child state to activate when entering a composite parent state; otherwise, null. + /// Optional subscription message types. /// The current instance, enabling method chaining. /// The type of the state to register. Must implement . /// Thrown if a state with the specified stateId is already registered or if the state factory returns null. @@ -86,7 +97,7 @@ StateMachine RegisterState(TStateId stateId, TStateId? onSucce /// Use this method to add states and define their transitions and hierarchy before starting the /// state machine. Registering duplicate state identifiers is not allowed. /// - StateMachine RegisterState(TStateId stateId, TStateId? onSuccess, TStateId? onError, TStateId? onFailure, TStateId? parentStateId = null, bool isCompositeParent = false, TStateId? initialChildStateId = null) + StateMachine RegisterState(TStateId stateId, TStateId? onSuccess, TStateId? onError, TStateId? onFailure, TStateId? parentStateId = null, bool isCompositeParent = false, TStateId? initialChildStateId = null, IReadOnlyCollection? commandSubscriptionTypes = null) where TState : class, IState; /// @@ -99,8 +110,9 @@ StateMachine RegisterState(TStateId stateId, TStateId? onSucce /// The identifier of the state to transition to when the state completes successfully, or null to return to the parent composite state. /// The identifier of the state to transition to when the registered state encounters an error, or null if no transition is defined. /// The identifier of the state to transition to when the registered state fails, or null if no transition is defined. + /// Optional subscription message types. /// The current instance, enabling method chaining. - StateMachine RegisterSubState(TStateId stateId, TStateId parentStateId, TStateId? onSuccess = null, TStateId? onError = null, TStateId? onFailure = null) + StateMachine RegisterSubState(TStateId stateId, TStateId parentStateId, TStateId? onSuccess = null, TStateId? onError = null, TStateId? onFailure = null, IReadOnlyCollection? commandSubscriptionTypes = null) where TChildClass : class, IState; /// Starts the machine at the initial state. @@ -109,7 +121,4 @@ StateMachine RegisterSubState(TStateId stateId, TStateId /// Async task of The current instance, enabling method chaining. /// Thrown if the specified state identifier has not been registered. Task> RunAsync(TStateId initialState, CancellationToken cancellationToken = default); - /////// Parameter stack . - /////// Error stack . - ////Task> RunAsync(TStateId initialState, PropertyBag? parameterStack = null, PropertyBag? errors = null, CancellationToken cancellationToken = default); } diff --git a/source/Lite.StateMachine/StateMachine.cs b/source/Lite.StateMachine/StateMachine.cs index dfda020..e0bb1a2 100644 --- a/source/Lite.StateMachine/StateMachine.cs +++ b/source/Lite.StateMachine/StateMachine.cs @@ -77,16 +77,13 @@ public StateMachine( /// public int DefaultStateTimeoutMs { get; set; } = Timeout.Infinite; - /// Gets or sets a value indicating whether substate-added context persists when returning to the parent (default: true). + /// public bool IsContextPersistent { get; set; } = true; /// public List States => [.. _states.Keys]; - /// Preload properties and errors to the context. - /// Parameter properties to safely add/update. - /// Error properties to safely add/update. - /// Instance of this class. + /// public StateMachine AddContext(PropertyBag? parameters = null, PropertyBag? errors = null) { if (parameters is not null) @@ -128,10 +125,15 @@ public StateMachine RegisterComposite( } /// - public StateMachine RegisterState(TStateId stateId, TStateId? onSuccess = null, TStateId? onError = null, TStateId? onFailure = null) + public StateMachine RegisterState( + TStateId stateId, + TStateId? onSuccess = null, + TStateId? onError = null, + TStateId? onFailure = null, + IReadOnlyCollection? subscriptionTypes = null) where TStateClass : class, IState { - return RegisterState(stateId, onSuccess, onError: null, onFailure: null, parentStateId: null, isCompositeParent: false, initialChildStateId: null); + return RegisterState(stateId, onSuccess, onError: null, onFailure: null, parentStateId: null, isCompositeParent: false, initialChildStateId: null, subscriptionTypes: subscriptionTypes); } /// @@ -142,7 +144,8 @@ public StateMachine RegisterState( TStateId? onFailure, TStateId? parentStateId = null, bool isCompositeParent = false, - TStateId? initialChildStateId = null) + TStateId? initialChildStateId = null, + IReadOnlyCollection? subscriptionTypes = null) where TStateClass : class, IState { if (_states.ContainsKey(stateId)) @@ -160,7 +163,7 @@ public StateMachine RegisterState( OnSuccess = onSuccess, OnError = onError, OnFailure = onFailure, - //// vNext: SubscribedMessages = cmdMsgs ?? [], + SubscribedMessageTypes = subscriptionTypes ?? [], }; _states[stateId] = reg; @@ -201,7 +204,8 @@ public StateMachine RegisterSubState( TStateId parentStateId, TStateId? onSuccess = null, TStateId? onError = null, - TStateId? onFailure = null) + TStateId? onFailure = null, + IReadOnlyCollection? subscriptionTypes = null) where TChildClass : class, IState { if (!_states.TryGetValue(parentStateId, out var pr) || !pr.IsCompositeParent) @@ -218,7 +222,8 @@ public StateMachine RegisterSubState( onFailure, parentStateId, isCompositeParent: false, - initialChildStateId: null); + initialChildStateId: null, + subscriptionTypes: subscriptionTypes); } /// @@ -447,9 +452,18 @@ private StateRegistration GetRegistration(TStateId stateId) { if (_eventAggregator is not null) { - // Subscribed message types or `Array.Empty()` for none - //// vNext (#89): IReadOnlyCollection types2 = [.. cmd.SubscribedMessageTypes ?? [], .. reg.SubscribedMessageTypes ?? []]; - var types = cmd.SubscribedMessageTypes ?? []; + // Apply the Highlander rule! + var typeSet = new HashSet(); + foreach (var preReg in reg.SubscribedMessageTypes ?? []) + typeSet.Add(preReg); + + foreach (var preReg in cmd.SubscribedMessageTypes ?? []) + typeSet.Add(preReg); + + IReadOnlyCollection types = [.. typeSet]; + + // The following runs risk of duplicates + ////IReadOnlyCollection types = [.. cmd.SubscribedMessageTypes ?? [], .. reg.SubscribedMessageTypes ?? []]; subscription = _eventAggregator.Subscribe(async (msgObj) => { diff --git a/source/Lite.StateMachine/StateRegistration.cs b/source/Lite.StateMachine/StateRegistration.cs index a0d09e1..1cfb717 100644 --- a/source/Lite.StateMachine/StateRegistration.cs +++ b/source/Lite.StateMachine/StateRegistration.cs @@ -38,6 +38,6 @@ internal sealed class StateRegistration /// Gets the State Id, used by ExportUml for . public TStateId StateId { get; init; } - /////// Gets the messages for to subscribe to. - ////public System.Collections.Generic.IReadOnlyCollection? SubscribedMessageTypes { get; init; } = null; + /// Gets the messages for to subscribe to. + public System.Collections.Generic.IReadOnlyCollection? SubscribedMessageTypes { get; init; } = null; }