Skip to content

Commit

Permalink
Merge pull request #574 from mclift/550-can-fire-with-trigger-parameters
Browse files Browse the repository at this point in the history
Add overloads to CanFire to support trigger parameters
  • Loading branch information
mclift authored May 13, 2024
2 parents 67b18fa + 0f2396e commit 2e79d56
Show file tree
Hide file tree
Showing 2 changed files with 298 additions and 3 deletions.
118 changes: 117 additions & 1 deletion src/Stateless/StateMachine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,12 @@ public bool IsInState(TState state)
/// Returns true if <paramref name="trigger"/> can be fired
/// in the current state.
/// </summary>
/// <remarks>
/// When the trigger is configured with parameters, the <c>default</c> value of each of the trigger parameter's types will be used
/// to evaluate whether it can fire, which may not be the desired behavior; to check if a trigger can be fired with specific arguments,
/// use the overload of <c>CanFire&lt;TArg1[, TArg2[ ,TArg3]]&gt;(TriggerWithParameters&lt;TArg1[, TArg2[ ,TArg3]]&gt;, ...)</c> that
/// matches the type arguments of your trigger.
/// </remarks>
/// <param name="trigger">Trigger to test.</param>
/// <returns>True if the trigger can be fired, false otherwise.</returns>
public bool CanFire(TTrigger trigger)
Expand All @@ -579,8 +585,64 @@ public bool CanFire(TTrigger trigger)

/// <summary>
/// Returns true if <paramref name="trigger"/> can be fired
/// in the current state.
/// in the current state using the supplied trigger argument.
/// </summary>
/// <typeparam name="TArg0">Type of the first trigger argument.</typeparam>
/// <param name="trigger">Trigger to test.</param>
/// <param name="arg0">The first argument.</param>
/// <returns>True if the trigger can be fired, false otherwise.</returns>
public bool CanFire<TArg0>(TriggerWithParameters<TArg0> trigger, TArg0 arg0)
{
if (trigger == null) throw new ArgumentNullException(nameof(trigger));

return CurrentRepresentation.CanHandle(trigger.Trigger, arg0);
}

/// <summary>
/// Returns true if <paramref name="trigger"/> can be fired
/// in the current state using the supplied trigger arguments.
/// </summary>
/// <typeparam name="TArg0">Type of the first trigger argument.</typeparam>
/// <typeparam name="TArg1">Type of the second trigger argument.</typeparam>
/// <param name="trigger">Trigger to test.</param>
/// <param name="arg0">The first argument.</param>
/// <param name="arg1">The second argument.</param>
/// <returns>True if the trigger can be fired, false otherwise.</returns>
public bool CanFire<TArg0, TArg1>(TriggerWithParameters<TArg0, TArg1> trigger, TArg0 arg0, TArg1 arg1)
{
if (trigger == null) throw new ArgumentNullException(nameof(trigger));

return CurrentRepresentation.CanHandle(trigger.Trigger, arg0, arg1);
}

/// <summary>
/// Returns true if <paramref name="trigger"/> can be fired
/// in the current state using the supplied trigger arguments.
/// </summary>
/// <typeparam name="TArg0">Type of the first trigger argument.</typeparam>
/// <typeparam name="TArg1">Type of the second trigger argument.</typeparam>
/// <typeparam name="TArg2">Type of the third trigger argument.</typeparam>
/// <param name="trigger">Trigger to test.</param>
/// <param name="arg0">The first argument.</param>
/// <param name="arg1">The second argument.</param>
/// <param name="arg2">The third argument.</param>
/// <returns>True if the trigger can be fired, false otherwise.</returns>
public bool CanFire<TArg0, TArg1, TArg2>(TriggerWithParameters<TArg0, TArg1, TArg2> trigger, TArg0 arg0, TArg1 arg1, TArg2 arg2)
{
if (trigger == null) throw new ArgumentNullException(nameof(trigger));

return CurrentRepresentation.CanHandle(trigger.Trigger, arg0, arg1, arg2);
}

/// <summary>
/// Returns true if <paramref name="trigger"/> can be fired in the current state.
/// </summary>
/// <remarks>
/// When the trigger is configured with parameters, the <c>default</c> value of each of the trigger parameter's types will be used
/// to evaluate whether it can fire, which may not be the desired behavior; to check if a trigger can be fired with specific arguments,
/// use the overload of <c>CanFire&lt;TArg1[, TArg2[ ,TArg3]]&gt;(TriggerWithParameters&lt;TArg1[, TArg2[ ,TArg3]]&gt;, ...)</c> that
/// matches the type arguments of your trigger.
/// </remarks>
/// <param name="trigger">Trigger to test.</param>
/// <param name="unmetGuards">Guard descriptions of unmet guards. If given trigger is not configured for current state, this will be null.</param>
/// <returns>True if the trigger can be fired, false otherwise.</returns>
Expand All @@ -589,6 +651,60 @@ public bool CanFire(TTrigger trigger, out ICollection<string> unmetGuards)
return CurrentRepresentation.CanHandle(trigger, new object[] { }, out unmetGuards);
}

/// <summary>
/// Returns true if <paramref name="trigger"/> can be fired
/// in the current state using the supplied trigger argument.
/// </summary>
/// <typeparam name="TArg0">Type of the first trigger argument.</typeparam>
/// <param name="trigger">Trigger to test.</param>
/// <param name="arg0">The first argument.</param>
/// <param name="unmetGuards">Guard descriptions of unmet guards. If given trigger is not configured for current state, this will be null.</param>
/// <returns>True if the trigger can be fired, false otherwise.</returns>
public bool CanFire<TArg0>(TriggerWithParameters<TArg0> trigger, TArg0 arg0, out ICollection<string> unmetGuards)
{
if (trigger == null) throw new ArgumentNullException(nameof(trigger));

return CurrentRepresentation.CanHandle(trigger.Trigger, new object[] { arg0 }, out unmetGuards);
}

/// <summary>
/// Returns true if <paramref name="trigger"/> can be fired
/// in the current state using the supplied trigger arguments.
/// </summary>
/// <typeparam name="TArg0">Type of the first trigger argument.</typeparam>
/// <typeparam name="TArg1">Type of the second trigger argument.</typeparam>
/// <param name="trigger">Trigger to test.</param>
/// <param name="arg0">The first argument.</param>
/// <param name="arg1">The second argument.</param>
/// <param name="unmetGuards">Guard descriptions of unmet guards. If given trigger is not configured for current state, this will be null.</param>
/// <returns>True if the trigger can be fired, false otherwise.</returns>
public bool CanFire<TArg0, TArg1>(TriggerWithParameters<TArg0, TArg1> trigger, TArg0 arg0, TArg1 arg1, out ICollection<string> unmetGuards)
{
if (trigger == null) throw new ArgumentNullException(nameof(trigger));

return CurrentRepresentation.CanHandle(trigger.Trigger, new object[] { arg0, arg1 }, out unmetGuards);
}

/// <summary>
/// Returns true if <paramref name="trigger"/> can be fired
/// in the current state using the supplied trigger arguments.
/// </summary>
/// <typeparam name="TArg0">Type of the first trigger argument.</typeparam>
/// <typeparam name="TArg1">Type of the second trigger argument.</typeparam>
/// <typeparam name="TArg2">Type of the third trigger argument.</typeparam>
/// <param name="trigger">Trigger to test.</param>
/// <param name="arg0">The first argument.</param>
/// <param name="arg1">The second argument.</param>
/// <param name="arg2">The third argument.</param>
/// <param name="unmetGuards">Guard descriptions of unmet guards. If given trigger is not configured for current state, this will be null.</param>
/// <returns>True if the trigger can be fired, false otherwise.</returns>
public bool CanFire<TArg0, TArg1, TArg2>(TriggerWithParameters<TArg0, TArg1, TArg2> trigger, TArg0 arg0, TArg1 arg1, TArg2 arg2, out ICollection<string> unmetGuards)
{
if (trigger == null) throw new ArgumentNullException(nameof(trigger));

return CurrentRepresentation.CanHandle(trigger.Trigger, new object[] { arg0, arg1, arg2 }, out unmetGuards);
}

/// <summary>
/// A human-readable representation of the state machine.
/// </summary>
Expand Down
183 changes: 181 additions & 2 deletions test/Stateless.Tests/StateMachineFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ public void WhenInSubstate_TriggerSuperStateTwiceToSameSubstate_DoesNotReenterSu
var eCount = 0;

sm.Configure(State.B)
.OnEntry(() => { eCount++;})
.OnEntry(() => { eCount++; })
.SubstateOf(State.C);

sm.Configure(State.A)
Expand Down Expand Up @@ -721,6 +721,185 @@ public void ExceptionWhenPermitIfHasMultipleNonExclusiveGuards()
Assert.Throws<InvalidOperationException>(() => sm.Fire(x, 2));
}

[Fact]
public void CanFire_When_Transition_Is_Conditional_On_Default_Value_And_Trigger_Parameters_Are_Omitted_Returns_True()
{
// This test verifies behavior in CanFire that may be considered a bug.
// When a PermitIf transition is configured with a parameterized trigger and the trigger is fired without parameters,
// CanFire will test the transition with the default values of the omitted trigger parameters' types.
var sm = new StateMachine<State, Trigger>(State.A);
var valueTrigger = sm.SetTriggerParameters<int>(Trigger.X);
sm.Configure(State.A).PermitIf(valueTrigger, State.B, i => i == default);

Assert.True(sm.CanFire(Trigger.X));
}

[Fact]
public void CanFire_When_Transition_Is_Conditional_On_Non_Default_Value_And_Trigger_Parameters_Are_Omitted_Returns_False()
{
// This test verifies behavior in CanFire that may be considered a bug.
// When a PermitIf transition is configured with a parameterized trigger and the trigger is fired without parameters,
// CanFire will test the transition with the default values of the omitted trigger parameters' types.
var sm = new StateMachine<State, Trigger>(State.A);
var valueTrigger = sm.SetTriggerParameters<int>(Trigger.X);
sm.Configure(State.A).PermitIf(valueTrigger, State.B, i => i == 1);

Assert.False(sm.CanFire(Trigger.X));
}

[Fact]
public void CanFire_When_Transition_Is_Conditional_And_One_Trigger_Parameter_Is_Used_And_Condition_Is_Met_Returns_True()
{
var sm = new StateMachine<State, Trigger>(State.A);
var valueTrigger = sm.SetTriggerParameters<int>(Trigger.X);
sm.Configure(State.A).PermitIf(valueTrigger, State.B, i => i == 1);

Assert.True(sm.CanFire(valueTrigger, 1));
}

[Fact]
public void CanFire_When_Transition_Is_Conditional_And_One_Trigger_Parameter_Is_Used_And_Condition_Is_NotMet_Returns_False()
{
var sm = new StateMachine<State, Trigger>(State.A);
var valueTrigger = sm.SetTriggerParameters<int>(Trigger.X);
sm.Configure(State.A).PermitIf(valueTrigger, State.B, i => i != 1);

Assert.False(sm.CanFire(valueTrigger, 1));
}

[Fact]
public void CanFire_When_Transition_Is_Conditional_And_Two_Trigger_Parameters_Are_Used_And_Condition_Is_Met_Returns_True()
{
var sm = new StateMachine<State, Trigger>(State.A);
var valueTrigger = sm.SetTriggerParameters<int, string>(Trigger.X);
sm.Configure(State.A).PermitIf(valueTrigger, State.B, (i, s) => i == 1 && s.Equals("X", StringComparison.Ordinal));

Assert.True(sm.CanFire(valueTrigger, 1, "X"));
}

[Fact]
public void CanFire_When_Transition_Is_Conditional_And_Two_Trigger_Parameters_Are_Used_And_Condition_Is_Not_Met_Returns_False()
{
var sm = new StateMachine<State, Trigger>(State.A);
var valueTrigger = sm.SetTriggerParameters<int, string>(Trigger.X);
sm.Configure(State.A).PermitIf(valueTrigger, State.B, (i, s) => i != 1 && s.Equals("Y", StringComparison.Ordinal));

Assert.False(sm.CanFire(valueTrigger, 1, "X"));
}

[Fact]
public void CanFire_When_Transition_Is_Conditional_And_Three_Trigger_Parameters_Are_Used_And_Condition_Is_Met_Returns_True()
{
var sm = new StateMachine<State, Trigger>(State.A);
var valueTrigger = sm.SetTriggerParameters<int, string, bool>(Trigger.X);
sm.Configure(State.A).PermitIf(valueTrigger, State.B, (i, s, b) => i == 1 && s.Equals("X", StringComparison.Ordinal) && b);

Assert.True(sm.CanFire(valueTrigger, 1, "X", true));
}

[Fact]
public void CanFire_When_Transition_Is_Conditional_And_Three_Trigger_Parameters_Are_Used_And_Condition_Is_Not_Met_Returns_False()
{
var sm = new StateMachine<State, Trigger>(State.A);
var valueTrigger = sm.SetTriggerParameters<int, string, bool>(Trigger.X);
sm.Configure(State.A).PermitIf(valueTrigger, State.B, (i, s, b) => i != 1 && s.Equals("Y", StringComparison.Ordinal) && !b);

Assert.False(sm.CanFire(valueTrigger, 1, "X", true));
}

[Fact]
public void CanFire_When_Transition_Is_Contidional_And_Has_One_Trigger_Parameter_And_A_Guard_Condition_Is_Met_Returns_Empty_Collection()
{
var sm = new StateMachine<State, Trigger>(State.A);
var valueTrigger = sm.SetTriggerParameters<int>(Trigger.X);
sm.Configure(State.A)
.PermitIf(valueTrigger, State.B, i => i == 1, "i equal to 1")
.PermitIf(valueTrigger, State.B, i => i == 2, "i equal to 2");

bool result = sm.CanFire(valueTrigger, 1, out ICollection<string> unmetGuards);

Assert.True(result);
Assert.False(unmetGuards.Any());
}

[Fact]
public void CanFire_When_Transition_Is_Contidional_And_Has_One_Trigger_Parameter_And_No_Guard_Conditions_Are_Met_Returns_Guard_Conditions()
{
var sm = new StateMachine<State, Trigger>(State.A);
var valueTrigger = sm.SetTriggerParameters<int>(Trigger.X);
sm.Configure(State.A)
.PermitIf(valueTrigger, State.B, i => i == 1, "i equal to 1")
.PermitIf(valueTrigger, State.B, i => i == 2, "i equal to 2");

bool result = sm.CanFire(valueTrigger, 3, out ICollection<string> unmetGuards);

Assert.Collection(unmetGuards,
item => Assert.Equal("i equal to 1", item),
item => Assert.Equal("i equal to 2", item));
}

[Fact]
public void CanFire_When_Transition_Is_Contidional_And_Has_Two_Trigger_Parameters_And_A_Guard_Condition_Is_Met_Returns_Empty_Collection()
{
var sm = new StateMachine<State, Trigger>(State.A);
var valueTrigger = sm.SetTriggerParameters<int, string>(Trigger.X);
sm.Configure(State.A)
.PermitIf(valueTrigger, State.B, (i, s) => i == 1 && s == "X", "i equal to 1 and s equal to 'X'")
.PermitIf(valueTrigger, State.B, (i, s) => i == 2 && s == "X", "i equal to 2 and s equal to 'Y'");

bool result = sm.CanFire(valueTrigger, 1, "X", out ICollection<string> unmetGuards);

Assert.True(result);
Assert.False(unmetGuards.Any());
}

[Fact]
public void CanFire_When_Transition_Is_Contidional_And_Has_Two_Trigger_Parameters_And_No_Guard_Conditions_Are_Met_Returns_Guard_Conditions()
{
var sm = new StateMachine<State, Trigger>(State.A);
var valueTrigger = sm.SetTriggerParameters<int, string>(Trigger.X);
sm.Configure(State.A)
.PermitIf(valueTrigger, State.B, (i, s) => i == 1 && s == "X", "i equal to 1 and s equal to 'X'")
.PermitIf(valueTrigger, State.B, (i, s) => i == 2 && s == "X", "i equal to 2 and s equal to 'Y'");

bool result = sm.CanFire(valueTrigger, 3, "Z", out ICollection<string> unmetGuards);

Assert.Collection(unmetGuards,
item => Assert.Equal("i equal to 1 and s equal to 'X'", item),
item => Assert.Equal("i equal to 2 and s equal to 'Y'", item));
}

[Fact]
public void CanFire_When_Transition_Is_Contidional_And_Has_Three_Trigger_Parameters_And_A_Guard_Condition_Is_Met_Returns_Empty_Collection()
{
var sm = new StateMachine<State, Trigger>(State.A);
var valueTrigger = sm.SetTriggerParameters<int, string, bool>(Trigger.X);
sm.Configure(State.A)
.PermitIf(valueTrigger, State.B, (i, s, b) => i == 1 && s == "X", "i equal to 1 and s equal to 'X' and boolean is true")
.PermitIf(valueTrigger, State.B, (i, s, b) => i == 2 && s == "X", "i equal to 2 and s equal to 'Y' and boolean is true");

bool result = sm.CanFire(valueTrigger, 1, "X", true, out ICollection<string> unmetGuards);

Assert.True(result);
Assert.False(unmetGuards.Any());
}

[Fact]
public void CanFire_When_Transition_Is_Contidional_And_Has_Three_Trigger_Parameters_And_No_Guard_Conditions_Are_Met_Returns_Guard_Conditions()
{
var sm = new StateMachine<State, Trigger>(State.A);
var valueTrigger = sm.SetTriggerParameters<int, string, bool>(Trigger.X);
sm.Configure(State.A)
.PermitIf(valueTrigger, State.B, (i, s, b) => i == 1 && s == "X", "i equal to 1 and s equal to 'X' and boolean is true")
.PermitIf(valueTrigger, State.B, (i, s, b) => i == 2 && s == "X", "i equal to 2 and s equal to 'Y' and boolean is true");

bool result = sm.CanFire(valueTrigger, 3, "Z", false, out ICollection<string> unmetGuards);

Assert.Collection(unmetGuards,
item => Assert.Equal("i equal to 1 and s equal to 'X' and boolean is true", item),
item => Assert.Equal("i equal to 2 and s equal to 'Y' and boolean is true", item));
}

[Fact]
public void TransitionWhenPermitDyanmicIfHasMultipleExclusiveGuards()
{
Expand Down Expand Up @@ -1048,7 +1227,7 @@ public void CanFire_GetUnmetGuardDescriptionsIfGuardFails()
const string guardDescription = "Guard failed";
var sm = new StateMachine<State, Trigger>(State.A);
sm.Configure(State.A)
.PermitIf(Trigger.X, State.B, ()=> false, guardDescription);
.PermitIf(Trigger.X, State.B, () => false, guardDescription);

bool result = sm.CanFire(Trigger.X, out ICollection<string> unmetGuards);

Expand Down

0 comments on commit 2e79d56

Please sign in to comment.