using Elsa.Testing.Shared; using Elsa.Workflows; using Elsa.Workflows.Activities.StateMachine.Models; using Elsa.Workflows.Options; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using StateMachineActivity = Elsa.Workflows.Activities.StateMachine.Activities.StateMachine; namespace Elsa.Activities.UnitTests.StateMachine; public class StateMachineTests { private const string CurrentStateProperty = "CurrentState"; private readonly WriteLine _newEntry = new("enter new") { Id = "new-entry" }; private readonly WriteLine _newExit = new("exit new") { Id = "new-exit" }; private readonly WriteLine _paidEntry = new("enter paid") { Id = "paid-entry" }; private readonly WriteLine _payTrigger = new("pay trigger") { Id = "pay-trigger" }; private readonly WriteLine _cancelTrigger = new("cancel trigger") { Id = "cancel-trigger" }; private readonly WriteLine _paidTrigger = new("paid trigger") { Id = "paid-trigger" }; private readonly WriteLine _payAction = new("pay action") { Id = "pay-action" }; private readonly StateMachineActivity _stateMachine; public StateMachineTests() { _stateMachine = new StateMachineActivity { InitialState = "New", States = { new StateMachineState { Name = "New", Entry = _newEntry, Exit = _newExit }, new StateMachineState { Name = "Paid", Entry = _paidEntry }, new StateMachineState { Name = "Closed" } }, Transitions = { new Transition { Name = "Pay", From = "New", To = "Paid", Trigger = _payTrigger, Condition = new(true), Action = _payAction }, new Transition { Name = "Cancel", From = "New", To = "Closed", Trigger = _cancelTrigger, Condition = new(true) }, new Transition { Name = "Close", From = "Paid", To = "Closed", Trigger = _paidTrigger } } }; } [Fact(DisplayName = "StateMachine schedules initial state entry before outbound triggers")] public async Task SchedulesInitialStateEntryBeforeOutboundTriggers() { var context = await ExecuteAsync(); Assert.Equal("New", _stateMachine.CurrentState); Assert.True(context.HasScheduledActivity(_newEntry)); Assert.False(context.HasScheduledActivity(_payTrigger)); } [Fact(DisplayName = "StateMachine schedules outbound triggers after entry completes")] public async Task SchedulesOutboundTriggersAfterEntryCompletes() { var context = await ExecuteAsync(); await CompleteScheduledActivityAsync(context, _newEntry); Assert.True(context.HasScheduledActivity(_payTrigger)); Assert.True(context.HasScheduledActivity(_cancelTrigger)); Assert.Equal(ActivityStatus.Running, context.Status); } [Fact(DisplayName = "StateMachine executes source exit, accepted transition action, target entry and target triggers")] public async Task ExecutesAcceptedTransitionPath() { var context = await ExecuteAndEnterNewStateAsync(); await CompleteScheduledActivityAsync(context, _payTrigger); Assert.True(context.HasScheduledActivity(_newExit)); Assert.False(context.HasScheduledActivity(_payAction)); await CompleteScheduledActivityAsync(context, _newExit); Assert.True(context.HasScheduledActivity(_payAction)); await CompleteScheduledActivityAsync(context, _payAction); Assert.Equal("Paid", _stateMachine.CurrentState); Assert.Equal("Paid", context.GetProperty(CurrentStateProperty)); Assert.True(context.HasScheduledActivity(_paidEntry)); await CompleteScheduledActivityAsync(context, _paidEntry); Assert.True(context.HasScheduledActivity(_paidTrigger)); Assert.Equal(ActivityStatus.Running, context.Status); } [Fact(DisplayName = "StateMachine treats missing transition condition as true")] public async Task MissingConditionAllowsTransition() { var payTransition = _stateMachine.Transitions.Single(x => x.Name == "Pay"); payTransition.Condition = null; payTransition.Action = null; _stateMachine.States.Single(x => x.Name == "New").Exit = null; _stateMachine.States.Single(x => x.Name == "Paid").Entry = null; var context = await ExecuteAndEnterNewStateAsync(); await CompleteScheduledActivityAsync(context, _payTrigger); Assert.Equal("Paid", _stateMachine.CurrentState); Assert.True(context.HasScheduledActivity(_paidTrigger)); } [Fact(DisplayName = "StateMachine evaluates a triggerless transition immediately after state entry")] public async Task TriggerlessTransitionIsEvaluatedImmediately() { var payTransition = _stateMachine.Transitions.Single(x => x.Name == "Pay"); payTransition.Trigger = null; payTransition.Condition = null; var context = await ExecuteAsync(); await CompleteScheduledActivityAsync(context, _newEntry); Assert.True(context.HasScheduledActivity(_newExit)); Assert.False(context.HasScheduledActivity(_cancelTrigger)); await CompleteScheduledActivityAsync(context, _newExit); await CompleteScheduledActivityAsync(context, _payAction); Assert.Equal("Paid", context.GetProperty(CurrentStateProperty)); Assert.True(context.HasScheduledActivity(_paidEntry)); } [Fact(DisplayName = "StateMachine schedules event triggers when triggerless transition conditions are false")] public async Task FalseTriggerlessConditionAllowsTriggeredTransitions() { var payTransition = _stateMachine.Transitions.Single(x => x.Name == "Pay"); payTransition.Trigger = null; payTransition.Condition = new(false); var context = await ExecuteAsync(); await CompleteScheduledActivityAsync(context, _newEntry); Assert.Equal("New", _stateMachine.CurrentState); Assert.False(context.HasScheduledActivity(_newExit)); Assert.True(context.HasScheduledActivity(_cancelTrigger)); Assert.Equal(ActivityStatus.Running, context.Status); } [Fact(DisplayName = "StateMachine leaves an all-false triggerless state active without rescheduling")] public async Task AllFalseTriggerlessTransitionsDoNotCompleteOrSpin() { _stateMachine.Transitions.Remove(_stateMachine.Transitions.Single(x => x.Name == "Cancel")); var payTransition = _stateMachine.Transitions.Single(x => x.Name == "Pay"); payTransition.Trigger = null; payTransition.Condition = new(false); var context = await ExecuteAsync(); await CompleteScheduledActivityAsync(context, _newEntry); Assert.Equal("New", _stateMachine.CurrentState); Assert.Equal(ActivityStatus.Running, context.Status); Assert.False(context.HasScheduledActivity(_newExit)); Assert.False(context.HasScheduledActivity(_payAction)); Assert.False(context.HasScheduledActivity(_paidEntry)); Assert.DoesNotContain(context.WorkflowExecutionContext.CompletionCallbacks, x => x.Owner == context); } [Fact(DisplayName = "StateMachine self-transition executes exit, action and entry in order")] public async Task SelfTransitionExecutesExitActionAndEntryInOrder() { var transition = _stateMachine.Transitions.Single(x => x.Name == "Pay"); transition.To = "New"; var context = await ExecuteAndEnterNewStateAsync(); await CompleteScheduledActivityAsync(context, _payTrigger); Assert.True(context.HasScheduledActivity(_newExit)); Assert.False(context.HasScheduledActivity(_payAction)); await CompleteScheduledActivityAsync(context, _newExit); Assert.True(context.HasScheduledActivity(_payAction)); await CompleteScheduledActivityAsync(context, _payAction); Assert.Equal("New", _stateMachine.CurrentState); Assert.True(context.HasScheduledActivity(_newEntry)); } [Fact(DisplayName = "StateMachine false transition condition leaves competing triggers active")] public async Task FalseConditionLeavesCompetingTriggersActive() { var context = await ExecuteAndEnterNewStateAsync(); _stateMachine.Transitions.Single(x => x.Name == "Pay").Condition = new(false); var cancelTriggerContext = await CreateScheduledActivityContextAsync(context, _cancelTrigger); var scheduledPayTriggerCount = CountScheduledActivities(context, _payTrigger); await CompleteScheduledActivityAsync(context, _payTrigger); Assert.Equal("New", _stateMachine.CurrentState); Assert.False(context.HasScheduledActivity(_payAction)); Assert.Equal(scheduledPayTriggerCount + 1, CountScheduledActivities(context, _payTrigger)); Assert.NotEqual(ActivityStatus.Canceled, cancelTriggerContext.Status); } [Fact(DisplayName = "StateMachine cancels competing outbound triggers when a transition wins")] public async Task AcceptedTransitionCancelsCompetingOutboundTriggers() { var context = await ExecuteAndEnterNewStateAsync(); var cancelTriggerContext = await CreateScheduledActivityContextAsync(context, _cancelTrigger); await CompleteScheduledActivityAsync(context, _payTrigger); Assert.Equal(ActivityStatus.Canceled, cancelTriggerContext.Status); } [Fact(DisplayName = "StateMachine removes re-armed competing triggers when a different transition wins")] public async Task AcceptedTransitionRemovesRearmedCompetingTriggers() { var context = await ExecuteAndEnterNewStateAsync(); _stateMachine.Transitions.Single(x => x.Name == "Pay").Condition = new(false); await CompleteScheduledActivityAsync(context, _payTrigger); Assert.True(context.WorkflowExecutionContext.Scheduler.Any(x => x.Activity == _payTrigger)); Assert.Contains(context.WorkflowExecutionContext.CompletionCallbacks, x => x.Owner == context && x.Child.Activity == _payTrigger); await CompleteScheduledActivityAsync(context, _cancelTrigger); Assert.False(context.WorkflowExecutionContext.Scheduler.Any(x => x.Activity == _payTrigger)); Assert.DoesNotContain(context.WorkflowExecutionContext.CompletionCallbacks, x => x.Owner == context && x.Child.Activity == _payTrigger); } [Fact(DisplayName = "StateMachine stores current state in the activity execution context")] public async Task StoresCurrentStateInActivityExecutionContext() { var payTransition = _stateMachine.Transitions.Single(x => x.Name == "Pay"); payTransition.Action = null; _stateMachine.States.Single(x => x.Name == "New").Exit = null; _stateMachine.States.Single(x => x.Name == "Paid").Entry = null; var context = await ExecuteAndEnterNewStateAsync(); await CompleteScheduledActivityAsync(context, _payTrigger); Assert.Equal("Paid", context.GetProperty(CurrentStateProperty)); } [Fact(DisplayName = "StateMachine resolves duplicate unnamed transition endpoints by scheduled transition")] public async Task ResolvesDuplicateUnnamedTransitionEndpointsByScheduledTransition() { var firstTrigger = new WriteLine("first trigger") { Id = "first-trigger" }; var secondTrigger = new WriteLine("second trigger") { Id = "second-trigger" }; var firstAction = new WriteLine("first action") { Id = "first-action" }; var secondAction = new WriteLine("second action") { Id = "second-action" }; var stateMachine = new StateMachineActivity { InitialState = "New", States = { new StateMachineState { Name = "New" }, new StateMachineState { Name = "Paid" } }, Transitions = { new Transition { From = "New", To = "Paid", Trigger = firstTrigger, Action = firstAction }, new Transition { From = "New", To = "Paid", Trigger = secondTrigger, Action = secondAction } } }; var context = await ExecuteAsync(stateMachine); await CompleteScheduledActivityAsync(context, secondTrigger); Assert.False(context.HasScheduledActivity(firstAction)); Assert.True(context.HasScheduledActivity(secondAction)); } [Fact(DisplayName = "StateMachine resolves duplicate named transitions by current state")] public async Task ResolvesDuplicateNamedTransitionsByCurrentState() { var payTrigger = new WriteLine("pay trigger") { Id = "pay-duplicate-trigger" }; var newCancelTrigger = new WriteLine("new cancel trigger") { Id = "new-cancel-trigger" }; var paidCancelTrigger = new WriteLine("paid cancel trigger") { Id = "paid-cancel-trigger" }; var stateMachine = new StateMachineActivity { InitialState = "New", States = { new StateMachineState { Name = "New" }, new StateMachineState { Name = "Paid" }, new StateMachineState { Name = "Closed" } }, Transitions = { new Transition { Name = "Cancel", From = "New", To = "Closed", Trigger = newCancelTrigger }, new Transition { Name = "Pay", From = "New", To = "Paid", Trigger = payTrigger }, new Transition { Name = "Cancel", From = "Paid", To = "Closed", Trigger = paidCancelTrigger } } }; var context = await ExecuteAsync(stateMachine); await CompleteScheduledActivityAsync(context, payTrigger); await CompleteScheduledActivityAsync(context, paidCancelTrigger); Assert.Equal("Closed", context.GetProperty(CurrentStateProperty)); } [Fact(DisplayName = "StateMachine ignores stale transition action completions")] public async Task IgnoresStaleTransitionActionCompletions() { var context = await ExecuteAndEnterNewStateAsync(); await CompleteScheduledActivityAsync(context, _payTrigger); await CompleteScheduledActivityAsync(context, _newExit); context.SetProperty(CurrentStateProperty, "Paid"); await CompleteScheduledActivityAsync(context, _payAction); Assert.False(context.HasScheduledActivity(_paidEntry)); } private async Task ExecuteAndEnterNewStateAsync() { var context = await ExecuteAsync(); await CompleteScheduledActivityAsync(context, _newEntry); return context; } private Task ExecuteAsync(StateMachineActivity? stateMachine = null) => new ActivityTestFixture(stateMachine ?? _stateMachine) .ConfigureServices(services => { services.RemoveAll(); services.AddSingleton(); }) .ExecuteAsync(); private static async Task CompleteScheduledActivityAsync(ActivityExecutionContext ownerContext, IActivity activity) { var childContext = await CreateScheduledActivityContextAsync(ownerContext, activity); var callback = PopCallback(ownerContext, activity); Assert.NotNull(callback?.CompletionCallback); await callback!.CompletionCallback!(new ActivityCompletedContext(ownerContext, childContext)); } private static async Task CreateScheduledActivityContextAsync(ActivityExecutionContext ownerContext, IActivity activity) { var callback = ownerContext.WorkflowExecutionContext.CompletionCallbacks.LastOrDefault(x => x.Owner == ownerContext && x.Child.Activity == activity); var childContext = await ownerContext.WorkflowExecutionContext.CreateActivityExecutionContextAsync(activity, new ActivityInvocationOptions { Owner = ownerContext, Tag = callback?.Tag }); childContext.TransitionTo(ActivityStatus.Running); ownerContext.WorkflowExecutionContext.AddActivityExecutionContext(childContext); return childContext; } private static int CountScheduledActivities(ActivityExecutionContext context, IActivity activity) => context.WorkflowExecutionContext.Scheduler.List().Count(x => x.Activity == activity); private static ActivityCompletionCallbackEntry? PopCallback(ActivityExecutionContext ownerContext, IActivity activity) { var callback = ownerContext.WorkflowExecutionContext.CompletionCallbacks.LastOrDefault(x => x.Owner == ownerContext && x.Child.Activity == activity); if (callback != null) ownerContext.WorkflowExecutionContext.RemoveCompletionCallback(callback); return callback; } }