diff --git a/docs/Concept Designs - Feature Considerations for 2026.md b/docs/Concept Designs - Feature Considerations for 2026.md new file mode 100644 index 0000000..cdf8908 --- /dev/null +++ b/docs/Concept Designs - Feature Considerations for 2026.md @@ -0,0 +1,41 @@ +# Lite.State - Feature Considerations + +**Table of Contents:** + +* [Lite.State - Feature Considerations](#litestate---feature-considerations) + * [How the system treats the last defined state transition](#how-the-system-treats-the-last-defined-state-transition) + * [Option to generate DotGraph of state transitions](#option-to-generate-dotgraph-of-state-transitions) + * [Custom Event Aggregator](#custom-event-aggregator) + +## How the system treats the last defined state transition + +**Date:** 2025-12-15 + +1. Sit at the last state and wait until told to go to the next state + * Awaits, context.NextState() + * PROs: + * Waits for the user to inform it. + * Idle state sits and waits for a triggering `OnMessage` without a defined `timeout`. + * CONs: + * Can sit without wraning +2. Auto-exit the StateMachine + * PROs: + * We could be done and can auto close the operation or application. + * CONs: + * Undesired exit of the operations/application + +## Option to generate DotGraph of state transitions + +1. PROs: + * Early discoverery of errors + * Auto-generated documentation +2. CONs: + * N/A +3. Limitations: + * Custom transitions may not be represented + +## Custom Event Aggregator + +Allow for built-in or 3rd-party event aggregator system. + +Requires interfaces and API hooks. diff --git a/docs/Concept Design - Mk2 - State Messages.md b/docs/Concept Designs - Robotic Flow Sample (2025).md similarity index 100% rename from docs/Concept Design - Mk2 - State Messages.md rename to docs/Concept Designs - Robotic Flow Sample (2025).md diff --git a/docs/Concept Designs.md b/docs/Concept Designs.md index 07da699..a183d3d 100644 --- a/docs/Concept Designs.md +++ b/docs/Concept Designs.md @@ -31,13 +31,13 @@ public class CarBuilder public CarBuilder WithMake(string make) { _car.Make = make; return this; } public CarBuilder WithModel(string model) { _car.Model = model; return this; } public CarBuilder WithColor(string color) { _car.Color = color; return this; } - + // Builder's `Build` method. public Car Build() { return _car; } } ``` -## Chosen +## Chosen ## Design Concepts @@ -122,7 +122,7 @@ public int MultiSuccess_OnEnter(Context params) * CON: How to strictly define which success state to exit to? * Cannot say, `return State.Success` - there are multiple successes -* CON: Cannot use `return State.Success;` +* CON: Cannot use `return State.Success;` #### 4) FREE - User sets whatever @@ -298,6 +298,9 @@ StateId InitOnEnter() ## Model B - State Defined Transitions +* Date: 2022-12-15 +* Modified: 2022-12-16 + ```cpp enum StateId = { diff --git a/source/Lite.State.Tests/StateTests/CommandStateTests.cs b/source/Lite.State.Tests/StateTests/CommandStateTests.cs new file mode 100644 index 0000000..d2d4e8a --- /dev/null +++ b/source/Lite.State.Tests/StateTests/CommandStateTests.cs @@ -0,0 +1,279 @@ +// Copyright Xeno Innovations, Inc. 2025 +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading.Tasks; + +namespace Lite.State.Tests.StateTests; + +[TestClass] +public class CommandStateTests +{ + public const string MessageTypeTest = "MessageType-Sent"; + public const string ParameterKeyTest = "TestKey"; + public const string TestValueBegin = "Initial-Value"; + public const string TestValueEnd = "Expected-Value"; + + public enum WorkflowState + { + Start, + Processing, // Composite state + Load, // Sub-state of Processing + Validate, // Sub-state of Processing + AwaitMessage, // Command state + Done, + Error, + Failed + } + + [TestMethod] + public void TransitionWithErrorToSuccessTest() + { + // Assemble + var aggregator = new EventAggregator(); + + var machine = new StateMachine(aggregator) + { + DefaultTimeoutMs = 3000 // as requested (can override per-command state) + }; + + // Register top-level states + machine.RegisterState(new StartState()); + var processing = new ProcessingState(); + machine.RegisterState(processing); + machine.RegisterState(new AwaitMessageState()); + machine.RegisterState(new DoneState()); + machine.RegisterState(new ErrorState()); + machine.RegisterState(new FailedState()); + + // Register sub-states inside Processing's submachine + var sub = processing.Submachine; + sub.RegisterState(new LoadState()); + sub.RegisterState(new ValidateState()); + + // Set initials + machine.SetInitial(WorkflowState.Start); + sub.SetInitial(WorkflowState.Load); + + // ==================== + // Act - Start workflow + var ctx = new PropertyBag { { ParameterKeyTest, TestValueBegin } }; + machine.Start(ctx); + + // Act - Simulate publishing a message to complete command state + // Publish after ~1 second (within timeout) — change timing to test OnTimeout + var msgObject = new PropertyBag() { { MessageTypeTest, TestValueBegin } }; + Task.Delay(1000).ContinueWith(_ => aggregator.Publish(msgObject)); + + // Keep console alive to observe async timeout/message handling + Task.Delay(5000).Wait(); + + // ================= + // Assert + var ctxFinal = machine.Context.Parameters; + Assert.IsNotNull(ctxFinal); + Assert.AreEqual(TestValueEnd, ctxFinal[ParameterKeyTest]); + } + + // Command state: AwaitMessage (listens to event aggregator; timeout defaults to 3000ms) + public sealed class AwaitMessageState : CommandState + { + public AwaitMessageState() : base(WorkflowState.AwaitMessage) + { + AddTransition(Result.Ok, WorkflowState.Done); + AddTransition(Result.Error, WorkflowState.Error); + AddTransition(Result.Failure, WorkflowState.Failed); + } + + // Optionally override default timeout: + // public override int? TimeoutOverrideMs => 5000; + + // Filter messages (optional). Here we only accept string messages that begin with "go". + ////public override Func MessageFilter => msg => + //// msg is string s && s.StartsWith("go", StringComparison.OrdinalIgnoreCase); + + public override void OnEnter(Context context) + { + Console.WriteLine("[AwaitMessage] OnEnter (subscribed; awaiting message)"); + } + + public override void OnEntering(Context context) + { + Console.WriteLine("[AwaitMessage] OnEntering"); + } + + public override void OnExit(Context context) + { + Console.WriteLine("[AwaitMessage] OnExit (unsubscribed; timer cancelled)"); + } + + public override void OnMessage(Context context, object message) + { + Console.WriteLine($"[AwaitMessage] OnMessage: '{message}' (timeout cancelled)"); + + if (message is PropertyBag prop && + prop is not null && + prop.ContainsKey(MessageTypeTest) && + prop[MessageTypeTest].Equals(TestValueBegin)) + { + context.Parameters[ParameterKeyTest] = TestValueEnd; + context.NextState(Result.Ok); + } + else + { + context.NextState(Result.Error); + } + + // Decide outcome based on message content + ////if (message is string s && s.Contains("error", StringComparison.OrdinalIgnoreCase)) + //// context.NextState(Result.Error); + ////else + //// context.NextState(Result.Ok); + } + + public override void OnTimeout(Context context) + { + Console.WriteLine("[AwaitMessage] OnTimeout: no messages received in time."); + context.NextState(Result.Failure); + } + } + + // Terminal states + public sealed class DoneState : BaseState + { + public DoneState() : base(WorkflowState.Done) + { + } + + public override void OnEnter(Context context) => + Console.WriteLine("[Done] OnEnter — workflow complete."); + + public override void OnEntering(Context context) => + Console.WriteLine("[Done] OnEntering"); + + public override void OnExit(Context context) => + Console.WriteLine("[Done] OnExit"); + } + + public sealed class ErrorState : BaseState + { + public ErrorState() : base(WorkflowState.Error) + { + } + + public override void OnEnter(Context context) => + Console.WriteLine("[Error] OnEnter"); + } + + public sealed class FailedState : BaseState + { + public FailedState() : base(WorkflowState.Failed) + { + } + + public override void OnEnter(Context context) => + Console.WriteLine("[Failed] OnEnter"); + } + + // Sub-state: Load (belongs to Processing submachine) + public sealed class LoadState : BaseState + { + public LoadState() : base(WorkflowState.Load) + { + AddTransition(Result.Ok, WorkflowState.Validate); + AddTransition(Result.Error, WorkflowState.Validate); // Example: still go validate to confirm + AddTransition(Result.Failure, WorkflowState.Validate); // Example: still go validate + } + + public override void OnEnter(Context context) + { + Console.WriteLine("[Load] OnEnter (loading resources)"); + + // Simulate outcome + context.NextState(Result.Ok); + } + + public override void OnEntering(Context context) => + Console.WriteLine("[Load] OnEntering (sub)"); + + public override void OnExit(Context context) => + Console.WriteLine("[Load] OnExit (sub)"); + } + + // Composite state: Processing (submachine controls Load -> Validate) + public sealed class ProcessingState : CompositeState + { + public ProcessingState() : base(WorkflowState.Processing) + { + // When submachine is done and bubbles Outcome: + // This parent state's transitions will be applied. + AddTransition(Result.Ok, WorkflowState.AwaitMessage); + AddTransition(Result.Error, WorkflowState.Error); + AddTransition(Result.Failure, WorkflowState.Failed); + } + + public override void OnEnter(Context context) => + Console.WriteLine("[Processing] OnEnter (starting submachine)"); + + public override void OnEntering(Context context) => + Console.WriteLine("[Processing] OnEntering"); + + public override void OnExit(Context context) => + Console.WriteLine("[Processing] OnExit (submachine exhausted)"); + } + + // Regular state: Start + public sealed class StartState : BaseState + { + public StartState() : base(WorkflowState.Start) + { + // Decide where to go based on outcome + AddTransition(Result.Ok, WorkflowState.Processing); + AddTransition(Result.Error, WorkflowState.Error); + AddTransition(Result.Failure, WorkflowState.Failed); + } + + public override void OnEnter(Context context) + { + Console.WriteLine($"[Start] OnEnter, Parameter='{context.Parameters[ParameterKeyTest]}'"); + + // Simulate work; then decide outcome + context.NextState(Result.Ok); + } + + public override void OnEntering(Context context) => + Console.WriteLine("[Start] OnEntering"); + + public override void OnExit(Context context) => + Console.WriteLine("[Start] OnExit"); + } + + // Sub-state: Validate (last sub-state; no local transition on Ok -> bubbles up) + public sealed class ValidateState : BaseState + { + public ValidateState() : base(WorkflowState.Validate) + { + // Local mapping only for non-OK; OK intentionally not mapped to demonstrate bubble-up. + AddTransition(Result.Error, WorkflowState.Validate); // example self-loop error check + AddTransition(Result.Failure, WorkflowState.Validate); // example self-loop failure check + } + + public override void OnEnter(Context context) + { + Console.WriteLine("[Validate] OnEnter (checking data)"); + + // Suppose validation passed: bubble up to Processing + context.NextState(Result.Ok); + } + + public override void OnEntering(Context context) + { + Console.WriteLine("[Validate] OnEntering (sub)"); + } + + public override void OnExit(Context context) + { + Console.WriteLine("[Validate] OnExit (sub)"); + } + } +} diff --git a/source/Lite.State.Tests/StateTests/CompositeStateTest.cs b/source/Lite.State.Tests/StateTests/CompositeStateTest.cs index 9199f3d..75440eb 100644 --- a/source/Lite.State.Tests/StateTests/CompositeStateTest.cs +++ b/source/Lite.State.Tests/StateTests/CompositeStateTest.cs @@ -104,6 +104,10 @@ private class State3(StateId id) { public override void OnEnter(Context context) { + // NOTE: Not needed, as this is the "last state" + // FUTURE CONSIDERATIONS: + // 1. Sit at this state, as it could be intended or an error + // 2. Or continue to allow the system to auto-exit (possible undesirable outcomes) // context.NextState(Result.Ok); } } diff --git a/source/Lite.State.Tests/StateTests/StateTransitionPocTest.cs b/source/Lite.State.Tests/StateTests/StateTransitionPocTest.cs index fde905f..3730172 100644 --- a/source/Lite.State.Tests/StateTests/StateTransitionPocTest.cs +++ b/source/Lite.State.Tests/StateTests/StateTransitionPocTest.cs @@ -17,7 +17,7 @@ public class StateTransitionPocTest public const string PARAM_TEST = "param1"; public const string SUCCESS = "success"; - public enum BasicFsm + public enum StateId { State1, State2, @@ -28,41 +28,50 @@ public enum BasicFsm [TestMethod] public void TransitionWithErrorToSuccessTest() { - var machine = new StateMachine(); - machine.RegisterState(stateId: BasicFsm.State1, stateClass: new State1(), onSuccess: BaseFsm.State2, onError: null, onFailure: null); - machine.RegisterState(stateid: BasicFsm.State2, stateClass: new State2(), onSuccess: BasicFsm.State3, onError: BasicFsm.State2Error, onFailure: null); - machine.RegisterState(stateid: BasicFsm.State2Error, stateClass: new State2Error(), onSuccess: BasicFsm.State2); - machine.RegisterState(stateid: BasicFsm.State3, stateClass: new State3(), onSuccess: null); + var machine = new StateMachine(); + // NOTE: We pass in the state Enum ID separately + machine.RegisterStateEx(stateId: StateId.State1, stateClass: new State1(), onSuccess: StateId.State2, onError: null, onFailure: null); + machine.RegisterStateEx(stateId: StateId.State2, stateClass: new State2(), onSuccess: StateId.State3, onError: StateId.State2Error, onFailure: null); + machine.RegisterStateEx(stateId: StateId.State2Error, stateClass: new State2Error(), onSuccess: StateId.State2); + machine.RegisterStateEx(stateId: StateId.State3, stateClass: new State3(), onSuccess: null); + + // ALT-2: Lazy-loaded classes (preferred) + // machine.RegisterStateEx(stateId: StateId.State1, onSuccess: StateId.State2, onError: null, onFailure: null); + // machine.RegisterStateEx(stateId: StateId.State2, onSuccess: StateId.State3, onError: StateId.State2Error, onFailure: null); + // machine.RegisterStateEx(stateId: StateId.State2Error, onSuccess: StateId.State2); + // machine.RegisterStateEx(stateId: StateId.State3, onSuccess: null); + // Set starting point - machine.SetInitial(BasicFsm.State1); + machine.SetInitial(StateId.State1); // Start your engine! - machine.Start("param-test"); + machine.Start(); - var finalParam = machine.Context.Parameter; - Assert.AreEqual(SUCCESS, finalParam); + var ctxFinalParams = machine.Context.Parameters; + Assert.IsNotNull(ctxFinalParams); + Assert.AreEqual(SUCCESS, ctxFinalParams[PARAM_TEST]); } //// private class State1 : IState - private class State1 : BaseState + private class State1 : BaseState { - public State1(BasicFsm id) : base(id) { } + public State1(StateId id) : base(id) { } - public override void OnEnter(Context context) + public override void OnEnter(Context context) { Console.WriteLine("[State1] OnEntering"); context.NextState(Result.Ok); } } - private class State2 : BaseState + private class State2 : BaseState { private int _counter = 0; - public State2(BasicFsm id) : base(id) { } + public State2(StateId id) : base(id) { } - public override void OnEnter(Context context) + public override void OnEnter(Context context) { _counter++; Console.WriteLine($"[State2] OnEntering: Counter={_counter}"); @@ -77,22 +86,22 @@ public override void OnEnter(Context context) } /// Simulated error state handler, goes back to State2. - private class State2Error : BaseState + private class State2Error : BaseState { - public State2Error(BasicFsm id) : base(id) { } + public State2Error(StateId id) : base(id) { } - public override void OnEnter(Context context) + public override void OnEnter(Context context) { Console.WriteLine("[State2Error] OnEntering"); context.NextState(Result.Ok); } } - private class State3 : BaseState + private class State3 : BaseState { - public State3(BasicFsm id) : base(id) { } + public State3(StateId id) : base(id) { } - public override void OnEntering(Context context) + public override void OnEntering(Context context) { context.Parameter = SUCCESS; Console.WriteLine("[State3] OnEntering"); diff --git a/source/Lite.State/CommandState.cs b/source/Lite.State/CommandState.cs index 34cb607..1cbc8f7 100644 --- a/source/Lite.State/CommandState.cs +++ b/source/Lite.State/CommandState.cs @@ -5,15 +5,14 @@ namespace Lite.State; -/// -/// A base class for command states, adding no behavior itself (machine handles timer/subscriptions). -/// +/// A base class for command states, adding no behavior itself (machine handles timer/subscriptions). public abstract class CommandState : BaseState, ICommandState where TState : struct, Enum { protected CommandState(TState id) : base(id) { } + /// Message filtration (string objects only). public virtual new Func MessageFilter => _ => true; public virtual int? TimeoutOverrideMs => null; diff --git a/source/Lite.State/StateMachine.cs b/source/Lite.State/StateMachine.cs index 580a340..e3fe40c 100644 --- a/source/Lite.State/StateMachine.cs +++ b/source/Lite.State/StateMachine.cs @@ -104,23 +104,27 @@ public StateMachine RegisterStateEx( public void SetInitial(TState initial) => _initial = initial; /// Starts the machine at the initial state. - public void Start(PropertyBag? parameters = null, PropertyBag? errorStack = null) + /// Initial parameter stack. + /// Error Stack . + public void Start(PropertyBag? initParameters = null, PropertyBag? errorStack = null) { if (_started) throw new InvalidOperationException("State machine already started."); if (!_states.TryGetValue(_initial, out var initialState)) throw new InvalidOperationException($"Initial state '{_initial}' is not registered."); _started = true; - ////var ctx = new Context(this) { Parameter = parameter }; + + // Same as below + ////var ctx = new Context(this) { Parameters = initParameters ?? [] }; ////typeof(StateMachine) //// .GetProperty(nameof(Context))! //// .SetValue(this, ctx); // Initialize the property bags - parameters ??= []; + initParameters ??= []; errorStack ??= []; - Context = new Context(this) { Parameters = parameters, ErrorStack = errorStack, }; + Context = new Context(this) { Parameters = initParameters, ErrorStack = errorStack, }; EnterState(initialState); } @@ -216,7 +220,8 @@ private void SetupCommandState(ICommandState cmd) _subscription = _eventAggregator.Subscribe(msg => { // Filter before delivery - if (!cmd.MessageFilter(msg)) return false; + if (!cmd.MessageFilter(msg)) + return false; // Cancel timeout upon first relevant message delivery _timeoutCts?.Cancel();