Skip to content
8 changes: 5 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ Flexible lightweight finite state machine (FSM) for .NET, supporting shared cont

The Lite State Machine is designed for vertical scaling. Meaning, it can be used for the most basic (tiny) system and beyond medical-grade robotics systems.

> Copyright 2021-2025 Xeno Innovations, Inc. (_dba, Suess Labs_)<br/>
> Created by: Damian Suess<br/>
> Date: 2021-06-07<br/>
||
|-|
| Copyright 2021-2025 Xeno Innovations, Inc. (_dba, Suess Labs_) |
| Created by: Damian Suess |
| Date: 2021-06-07 |

## Package Releases

Expand Down
5 changes: 2 additions & 3 deletions source/Lite.StateMachine.Tests/DiTests/MsDiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.Threading.Tasks;
using Lite.StateMachine.Tests.TestData;
using Lite.StateMachine.Tests.TestData.Services;
using Lite.StateMachine.Tests.TestData.States;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Console;
Expand Down Expand Up @@ -47,7 +48,6 @@ public async Task Basic_FlatStates_SuccessTestAsync()

var msgService = services.GetRequiredService<IMessageService>();
Assert.AreEqual(9, msgService.Counter1, "Message service should have 9 from the 3 states.");
Assert.HasCount(9, msgService.Messages, "Message service should have 9 messages from the 3 states.");

// Ensure all states are registered
var enums = Enum.GetValues<BasicStateId>().Cast<BasicStateId>();
Expand Down Expand Up @@ -153,6 +153,7 @@ public async Task Basic_LogLevelNone_SuccessTestAsync()
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]
/// <summary>Following demonstrates Composite + Command States with Dependency Injection.</summary>
/// <remarks>
/// NOTE:
Expand All @@ -163,7 +164,6 @@ public async Task Basic_LogLevelNone_SuccessTestAsync()
/// * You MUST publish while in the ParentSub_WaitMessageState
/// otherwise the message is never received (rightfully so).
/// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
[TestMethod]
public async Task RegisterState_MsDi_EventAggregatorOnly_SuccessTestAsync()
{
// Build DI
Expand Down Expand Up @@ -277,7 +277,6 @@ public async Task RegisterState_MsDi_EventAggregatorOnly_SuccessTestAsync()
Console.WriteLine("MS.DI workflow finished.");

Assert.AreEqual(2, msgService.Counter2);
Assert.HasCount(42, msgService.Messages);
Assert.AreEqual(42, msgService.Counter1);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Linq;
using System.Threading.Tasks;
using Lite.StateMachine.Tests.TestData;
using Lite.StateMachine.Tests.TestData.States;

namespace Lite.StateMachine.Tests.StateTests;

Expand Down
90 changes: 89 additions & 1 deletion source/Lite.StateMachine.Tests/StateTests/CompositeStateTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@
using System.Linq;
using System.Threading.Tasks;
using Lite.StateMachine.Tests.TestData;
using Lite.StateMachine.Tests.TestData.Services;
using Lite.StateMachine.Tests.TestData.States;
using Lite.StateMachine.Tests.TestData.States.CompositeL3DiStates;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Lite.StateMachine.Tests.StateTests;

[TestClass]
public class CompositeStateTest
public class CompositeStateTest : TestBase
{
public const string ParameterSubStateEntered = "SubEntered";
public const string SUCCESS = "success";
Expand Down Expand Up @@ -161,4 +166,87 @@ public void Level1_Fluent_RegisterState_SuccessTest()
// Ensure they're in order
Assert.IsTrue(enums.SequenceEqual(machine.States));
}

[TestMethod]
[DataRow(true, DisplayName = "Is Context Persisted")]
[DataRow(false, DisplayName = "Is NOT Context Persisted")]
public async Task Level3_IsContextPersistent_False_SuccessTestAsync(bool contextIsPersistent)
{
// Assemble - Using DI for MessageService's counters
var services = new ServiceCollection()
//// Register Services
.AddLogging(InlineTraceLogger(LogLevel.None))
.AddSingleton<IMessageService, MessageService>()
//// Register States (DI still works with states unregistered)
////.AddTransient<State1>()
////.AddTransient<State2>()
////.AddTransient<State2_Sub1>()
////.AddTransient<State2_Sub2>()
////.AddTransient<State2_Sub2_Sub1>()
////.AddTransient<State2_Sub2_Sub2>()
////.AddTransient<State2_Sub2_Sub3>()
////.AddTransient<State2_Sub3>()
////.AddTransient<State3>()
.BuildServiceProvider();

var msgService = services.GetRequiredService<IMessageService>();
Func<Type, object?> factory = t => ActivatorUtilities.CreateInstance(services, t);

var machine = new StateMachine<CompositeL3>(factory, null, isContextPersistent: contextIsPersistent);

machine
.RegisterState<State1>(CompositeL3.State1, CompositeL3.State2)
.RegisterComposite<State2>(CompositeL3.State2, initialChildStateId: CompositeL3.State2_Sub1, onSuccess: CompositeL3.State3)
.RegisterSubState<State2_Sub1>(CompositeL3.State2_Sub1, parentStateId: CompositeL3.State2, onSuccess: CompositeL3.State2_Sub2)
.RegisterCompositeChild<State2_Sub2>(CompositeL3.State2_Sub2, parentStateId: CompositeL3.State2, initialChildStateId: CompositeL3.State2_Sub2_Sub1, onSuccess: CompositeL3.State2_Sub3)
.RegisterSubState<State2_Sub2_Sub1>(CompositeL3.State2_Sub2_Sub1, parentStateId: CompositeL3.State2_Sub2, onSuccess: CompositeL3.State2_Sub2_Sub2)
.RegisterSubState<State2_Sub2_Sub2>(CompositeL3.State2_Sub2_Sub2, parentStateId: CompositeL3.State2_Sub2, onSuccess: CompositeL3.State2_Sub2_Sub3)
.RegisterSubState<State2_Sub2_Sub3>(CompositeL3.State2_Sub2_Sub3, parentStateId: CompositeL3.State2_Sub2, onSuccess: null)
.RegisterSubState<State2_Sub3>(CompositeL3.State2_Sub3, parentStateId: CompositeL3.State2, onSuccess: null)
.RegisterState<State3>(CompositeL3.State3, onSuccess: null);

// Act
await machine.RunAsync(CompositeL3.State1, cancellationToken: TestContext.CancellationToken);

// Assert
Assert.IsNotNull(machine);
Assert.IsNull(machine.Context);

// Ensure all states are registered
var enums = Enum.GetValues<CompositeL3>().Cast<CompositeL3>();
Assert.AreEqual(enums.Count(), machine.States.Count());
Assert.IsTrue(enums.All(stateId => machine.States.Contains(stateId)));

// State Transition counter (9 states, 3 transitions)
Assert.AreEqual(27, msgService.Counter1);

// Validate MessageService's data
foreach (var x in msgService.Messages)
Console.WriteLine(x);

Assert.HasCount(enums.Count(), msgService.Messages);

// Note that "State2" is a Composite state.
// When Context is NOT persisted, it will be removed on the next substate.
Assert.AreEqual("[Keys-State2]: State1,State2", msgService.Messages[1]);

if (contextIsPersistent)
{
// "[^1]" == "msgService.Messages.Count - 1"
Assert.AreEqual(
"[Keys-State3]: State1,State2,State2_Sub1,State2_Sub2,State2_Sub2_Sub1,State2_Sub2_Sub2,State2_Sub2_Sub3,State2_Sub3,State3",
msgService.Messages[^1]);
}
else
{
// The final does NOT include "State2" because it is a Composite state
// and Composite states tee-up parameters for the children to use. Thus they get discarded.
Assert.AreEqual(
"[Keys-State3]: State1,State3",
msgService.Messages[msgService.Messages.Count - 1]);

Assert.AreEqual(4, msgService.Counter2);
Assert.AreEqual(7, msgService.Counter3);
}
}
}
31 changes: 31 additions & 0 deletions source/Lite.StateMachine.Tests/StateTests/TestBase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright Xeno Innovations, Inc. 2025
// See the LICENSE file in the project root for more information.

using System;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Console;

namespace Lite.StateMachine.Tests.StateTests;

public class TestBase
{
/// <summary>ILogger Helper for generating clean in-line logs.</summary>
/// <param name="logLevel">Log level (Default: Trace).</param>
/// <returns><see cref="ILoggingBuilder"/>.</returns>
protected static Action<ILoggingBuilder> InlineTraceLogger(LogLevel logLevel = LogLevel.Trace)
{
// Creates in-line log format
return config =>
{
config.AddSimpleConsole(options =>
{
options.TimestampFormat = "HH:mm:ss.fff ";
options.UseUtcTimestamp = false;
options.IncludeScopes = true;
options.SingleLine = true;
options.ColorBehavior = LoggerColorBehavior.Enabled;
});
config.SetMinimumLevel(logLevel);
};
}
}
19 changes: 19 additions & 0 deletions source/Lite.StateMachine.Tests/TestData/Services/MessageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,48 @@
// See the LICENSE file in the project root for more information.

using System.Collections.Generic;
using Lite.StateMachine.Tests.TestData.States;

namespace Lite.StateMachine.Tests.TestData.Services;

[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1649:File name should match first type name", Justification = "Allowed for testing.")]
public interface IMessageService
{
/// <summary
/// Gets or sets a counter.
/// <see cref="DiStateBase{TStateClass, TStateId}"/> uses it as an automatic state transition counter.
/// </summary>
int Counter1 { get; set; }

/// <summary>Gets or sets the user's custom counter.</summary>
int Counter2 { get; set; }

/// <summary>Gets or sets the user's custom counter.</summary>
int Counter3 { get; set; }

/// <summary>Gets a list of user's custom messages.</summary>
List<string> Messages { get; }

/// <summary>Add message to read-only list.</summary>
/// <param name="message">Message.</param>
void AddMessage(string message);
}

public class MessageService : IMessageService
{
/// <inheritdoc/>
public int Counter1 { get; set; }

/// <inheritdoc/>
public int Counter2 { get; set; }

/// <inheritdoc/>
public int Counter3 { get; set; }

/// <inheritdoc/>
public List<string> Messages { get; } = [];

/// <inheritdoc/>
public void AddMessage(string message) =>
Messages.Add(message);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@
using Lite.StateMachine.Tests.TestData.Services;
using Microsoft.Extensions.Logging;

namespace Lite.StateMachine.Tests.TestData;
namespace Lite.StateMachine.Tests.TestData.States;

#pragma warning disable SA1649 // File name should match first type name
#pragma warning disable SA1402 // File may only contain a single type

public class BasicDiState1(IMessageService msg, ILogger<BasicDiState1> log)
: BaseStateDI<BasicDiState1, BasicStateId>(msg, log);
: DiStateBase<BasicDiState1, BasicStateId>(msg, log);

public class BasicDiState2(IMessageService msg, ILogger<BasicDiState2> log)
: BaseStateDI<BasicDiState2, BasicStateId>(msg, log);
: DiStateBase<BasicDiState2, BasicStateId>(msg, log);

public class BasicDiState3(IMessageService msg, ILogger<BasicDiState3> log)
: BaseStateDI<BasicDiState3, BasicStateId>(msg, log);
: DiStateBase<BasicDiState3, BasicStateId>(msg, log);

#pragma warning restore SA1649 // File name should match first type name
#pragma warning restore SA1402 // File may only contain a single type
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
using System;
using System.Threading.Tasks;

namespace Lite.StateMachine.Tests.TestData;
namespace Lite.StateMachine.Tests.TestData.States;

#pragma warning disable SA1649 // File name should match first type name
#pragma warning disable SA1402 // File may only contain a single type
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@
using Lite.StateMachine.Tests.TestData.Services;
using Microsoft.Extensions.Logging;

namespace Lite.StateMachine.Tests.TestData;
namespace Lite.StateMachine.Tests.TestData.States;

#pragma warning disable SA1649 // File name should match first type name
#pragma warning disable SA1402 // File may only contain a single type

public class EntryState(IMessageService msg, ILogger<EntryState> log)
: BaseStateDI<EntryState, CompositeMsgStateId>(msg, log)
: DiStateBase<EntryState, CompositeMsgStateId>(msg, log)
{
}

public class ParentState(IMessageService msg, ILogger<ParentState> log)
: BaseStateDI<ParentState, CompositeMsgStateId>(msg, log)
: DiStateBase<ParentState, CompositeMsgStateId>(msg, log)
{
/// <summary>Handle the result from our last child state.</summary>
/// <param name="context">Context data.</param>
Expand All @@ -40,12 +40,12 @@ public override Task OnExit(Context<CompositeMsgStateId> context)
}

public class ParentSub_FetchState(IMessageService msg, ILogger<ParentSub_FetchState> log)
: BaseStateDI<ParentSub_FetchState, CompositeMsgStateId>(msg, log)
: DiStateBase<ParentSub_FetchState, CompositeMsgStateId>(msg, log)
{
}

public class ParentSub_WaitMessageState(IMessageService msg, ILogger<ParentSub_WaitMessageState> log)
: BaseStateDI<ParentSub_WaitMessageState, CompositeMsgStateId>(msg, log),
: DiStateBase<ParentSub_WaitMessageState, CompositeMsgStateId>(msg, log),
ICommandState<CompositeMsgStateId>
{
public override Task OnEnter(Context<CompositeMsgStateId> context)
Expand Down Expand Up @@ -134,12 +134,12 @@ public Task OnTimeout(Context<CompositeMsgStateId> context)
}

public class Workflow_DoneState(IMessageService msg, ILogger<Workflow_DoneState> log)
: BaseStateDI<Workflow_DoneState, CompositeMsgStateId>(msg, log)
: DiStateBase<Workflow_DoneState, CompositeMsgStateId>(msg, log)
{
}

public class Workflow_ErrorState(IMessageService msg, ILogger<Workflow_ErrorState> log)
: BaseStateDI<Workflow_ErrorState, CompositeMsgStateId>(msg, log)
: DiStateBase<Workflow_ErrorState, CompositeMsgStateId>(msg, log)
{
public override Task OnEnter(Context<CompositeMsgStateId> context)
{
Expand All @@ -158,7 +158,7 @@ public override Task OnEnter(Context<CompositeMsgStateId> context)
}

public class Workflow_FailureState(IMessageService msg, ILogger<Workflow_FailureState> log)
: BaseStateDI<Workflow_FailureState, CompositeMsgStateId>(msg, log)
: DiStateBase<Workflow_FailureState, CompositeMsgStateId>(msg, log)
{
public override Task OnEnter(Context<CompositeMsgStateId> context)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
using System.Threading.Tasks;
using static Lite.StateMachine.Tests.StateTests.CompositeStateTest;

namespace Lite.StateMachine.Tests.TestData;
namespace Lite.StateMachine.Tests.TestData.States;

#pragma warning disable SA1649 // File name should match first type name
#pragma warning disable SA1402 // File may only contain a single type
Expand Down
Loading