It's amazing to see how such a great library as Stateless benefits when being configured in a more visually way - It completely speeds up the whole development process as the mental model is far easier to grasp.
However, during this testing I also wanted to make sure that both synchronous and asynchronous state machines work as expected, and in doing so I think I might have spotted an issue in how asynchronous calls in Stateless work with superstates/substates.
It's best to demonstrate with two 'identical' state machines.
First the synchronous one (which works as expected):
[Fact]
public void SuperStateShouldNotExitOnSubStateTransition_WhenUsingSyncTriggers()
{
// Arrange.
var sm = new StateMachine<State, Trigger>(State.A);
var record = new List<string>();
sm.Configure(State.A)
.OnEntry(() => record.Add("Entered state A"))
.OnExit(() => record.Add("Exited state A"))
.Permit(Trigger.X, State.B);
sm.Configure(State.B) // Our super state.
.InitialTransition(State.C)
.OnEntry(() => record.Add("Entered super state B"))
.OnExit(() => record.Add("Exited super state B"));
sm.Configure(State.C) // Our first sub state.
.OnEntry(() => record.Add("Entered sub state C"))
.OnExit(() => record.Add("Exited sub state C"))
.Permit(Trigger.Y, State.D)
.SubstateOf(State.B);
sm.Configure(State.D) // Our second sub state.
.OnEntry(() => record.Add("Entered sub state D"))
.OnExit(() => record.Add("Exited sub state D"))
.SubstateOf(State.B);
// Act.
sm.Fire(Trigger.X);
sm.Fire(Trigger.Y);
// Assert.
Assert.Equal("Exited state A", record[0]);
Assert.Equal("Entered super state B", record[1]);
Assert.Equal("Entered sub state C", record[2]);
Assert.Equal("Exited sub state C", record[3]);
Assert.Equal("Entered sub state D", record[4]);
}
And the asynchronous one (which doesn't work as expected)
[Fact]
public async Task SuperStateShouldNotExitOnSubStateTransition_WhenUsingAsyncTriggers()
{
// Arrange.
var sm = new StateMachine<State, Trigger>(State.A);
var record = new List<string>();
sm.Configure(State.A)
.OnEntryAsync(() => Task.Run(() => record.Add("Entered state A")))
.OnExitAsync(() => Task.Run(() => record.Add("Exited state A")))
.Permit(Trigger.X, State.B);
sm.Configure(State.B) // Our super state.
.InitialTransition(State.C)
.OnEntryAsync(() => Task.Run(() => record.Add("Entered super state B")))
.OnExitAsync(() => Task.Run(() => record.Add("Exited super state B")));
sm.Configure(State.C) // Our first sub state.
.OnEntryAsync(() => Task.Run(() => record.Add("Entered sub state C")))
.OnExitAsync(() => Task.Run(() => record.Add("Exited sub state C")))
.Permit(Trigger.Y, State.D)
.SubstateOf(State.B);
sm.Configure(State.D) // Our second sub state.
.OnEntryAsync(() => Task.Run(() => record.Add("Entered sub state D")))
.OnExitAsync(() => Task.Run(() => record.Add("Exited sub state D")))
.SubstateOf(State.B);
// Act.
await sm.FireAsync(Trigger.X).ConfigureAwait(false);
await sm.FireAsync(Trigger.Y).ConfigureAwait(false);
// Assert.
Assert.Equal("Exited state A", record[0]);
Assert.Equal("Entered super state B", record[1]);
Assert.Equal("Entered sub state C", record[2]);
Assert.Equal("Exited sub state C", record[3]);
Assert.Equal("Entered sub state D", record[4]); // Before the patch the actual result was "Exited super state B"
}
Both state machines follow the same transitions as specified below, but in case of the asynchronous executing one It seems that superstate B is exiting even on a transition between its substates.
This is caused by an inconsistency in ExitAsync (in StateRepresentation.Async.cs) compared to Exit (in StateRepresentation.cs). There seem to be some checks missing.
I've got a small correction ready and two additional unit tests to prove its feasibility and correctness.
Happy to assist Stateless with a PR to fix this: #445
Hi,
during the easter holidays the wetter was bad which gave me some time to work on a PlantUML 2 Stateless Roslyn code generator.
It's amazing to see how such a great library as Stateless benefits when being configured in a more visually way - It completely speeds up the whole development process as the mental model is far easier to grasp.
However, during this testing I also wanted to make sure that both synchronous and asynchronous state machines work as expected, and in doing so I think I might have spotted an issue in how asynchronous calls in Stateless work with superstates/substates.
It's best to demonstrate with two 'identical' state machines.
First the synchronous one (which works as expected):
And the asynchronous one (which doesn't work as expected)
Both state machines follow the same transitions as specified below, but in case of the asynchronous executing one It seems that superstate B is exiting even on a transition between its substates.
This is caused by an inconsistency in ExitAsync (in StateRepresentation.Async.cs) compared to Exit (in StateRepresentation.cs). There seem to be some checks missing. I've got a small correction ready and two additional unit tests to prove its feasibility and correctness.
Happy to assist Stateless with a PR to fix this: #445