702 lines
31 KiB
C#
702 lines
31 KiB
C#
using Elsa.Testing.Shared;
|
|
using Elsa.Mediator.Contracts;
|
|
using Elsa.Workflows;
|
|
using Elsa.Workflows.Activities.StateMachine.Models;
|
|
using Elsa.Workflows.Options;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using NSubstitute;
|
|
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<string>(CurrentStateProperty));
|
|
Assert.True(context.HasScheduledActivity(_paidEntry));
|
|
|
|
await CompleteScheduledActivityAsync(context, _paidEntry);
|
|
Assert.True(context.HasScheduledActivity(_paidTrigger));
|
|
Assert.Equal(ActivityStatus.Running, context.Status);
|
|
}
|
|
|
|
[Fact(DisplayName = "StateMachine exposes source entry, trigger, exit, action and target entry order")]
|
|
public async Task AcceptedTransitionExecutesObservableLifecycleOrder()
|
|
{
|
|
var lifecycle = new List<string>();
|
|
var context = await ExecuteAsync();
|
|
|
|
Assert.True(context.HasScheduledActivity(_newEntry));
|
|
Assert.False(context.HasScheduledActivity(_payTrigger));
|
|
|
|
await CompleteScheduledActivityAsync(context, _newEntry);
|
|
lifecycle.Add("source entry");
|
|
Assert.True(context.HasScheduledActivity(_payTrigger));
|
|
|
|
await CompleteScheduledActivityAsync(context, _payTrigger);
|
|
lifecycle.Add("trigger");
|
|
Assert.True(context.HasScheduledActivity(_newExit));
|
|
Assert.False(context.HasScheduledActivity(_payAction));
|
|
|
|
await CompleteScheduledActivityAsync(context, _newExit);
|
|
lifecycle.Add("source exit");
|
|
Assert.True(context.HasScheduledActivity(_payAction));
|
|
|
|
await CompleteScheduledActivityAsync(context, _payAction);
|
|
lifecycle.Add("action");
|
|
Assert.Equal("Paid", context.GetProperty<string>(CurrentStateProperty));
|
|
Assert.True(context.HasScheduledActivity(_paidEntry));
|
|
|
|
await CompleteScheduledActivityAsync(context, _paidEntry);
|
|
lifecycle.Add("target entry");
|
|
Assert.True(context.HasScheduledActivity(_paidTrigger));
|
|
|
|
Assert.Equal(new[] { "source entry", "trigger", "source exit", "action", "target entry" }, lifecycle);
|
|
}
|
|
|
|
[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<string>(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 completes after entering a terminal state")]
|
|
public async Task TerminalStateCompletesStateMachine()
|
|
{
|
|
var stateMachine = new StateMachineActivity
|
|
{
|
|
InitialState = "Done",
|
|
States = { new StateMachineState { Name = "Done" } }
|
|
};
|
|
|
|
var context = await ExecuteAsync(stateMachine);
|
|
|
|
Assert.Equal("Done", context.GetProperty<string>(CurrentStateProperty));
|
|
Assert.Equal(ActivityStatus.Completed, context.Status);
|
|
Assert.Empty(context.WorkflowExecutionContext.Scheduler.List());
|
|
}
|
|
|
|
[Fact(DisplayName = "StateMachine accepts the first eligible triggerless transition in declaration order")]
|
|
public async Task TriggerlessTransitionsUseDeclarationOrder()
|
|
{
|
|
var stateMachine = new StateMachineActivity
|
|
{
|
|
InitialState = "Source",
|
|
States =
|
|
{
|
|
new StateMachineState { Name = "Source" },
|
|
new StateMachineState { Name = "First" },
|
|
new StateMachineState { Name = "Second" }
|
|
},
|
|
Transitions =
|
|
{
|
|
new Transition { Name = "FirstTransition", From = "Source", To = "First", Condition = new(true) },
|
|
new Transition { Name = "SecondTransition", From = "Source", To = "Second", Condition = new(true) }
|
|
}
|
|
};
|
|
|
|
var context = await ExecuteAsync(stateMachine);
|
|
|
|
Assert.Equal("First", context.GetProperty<string>(CurrentStateProperty));
|
|
Assert.Equal(ActivityStatus.Completed, context.Status);
|
|
}
|
|
|
|
[Fact(DisplayName = "StateMachine yields an empty triggerless self-cycle to the workflow scheduler")]
|
|
public async Task EmptyTriggerlessSelfCycleYieldsToScheduler()
|
|
{
|
|
var stateMachine = new StateMachineActivity
|
|
{
|
|
InitialState = "A",
|
|
States = { new StateMachineState { Name = "A" } },
|
|
Transitions =
|
|
{
|
|
new Transition { From = "A", To = "A" }
|
|
}
|
|
};
|
|
|
|
var context = await ExecuteAsync(stateMachine);
|
|
|
|
Assert.Equal("A", context.GetProperty<string>(CurrentStateProperty));
|
|
Assert.Equal(ActivityStatus.Running, context.Status);
|
|
|
|
var continuation = context.WorkflowExecutionContext.Scheduler.Take();
|
|
await CompleteScheduledActivityAsync(context, continuation.Activity);
|
|
|
|
Assert.Equal("A", context.GetProperty<string>(CurrentStateProperty));
|
|
Assert.Equal(ActivityStatus.Running, context.Status);
|
|
Assert.Single(context.WorkflowExecutionContext.Scheduler.List());
|
|
}
|
|
|
|
[Fact(DisplayName = "StateMachine yields an empty triggerless two-state cycle to the workflow scheduler")]
|
|
public async Task EmptyTriggerlessTwoStateCycleYieldsToScheduler()
|
|
{
|
|
var stateMachine = new StateMachineActivity
|
|
{
|
|
InitialState = "A",
|
|
States =
|
|
{
|
|
new StateMachineState { Name = "A" },
|
|
new StateMachineState { Name = "B" }
|
|
},
|
|
Transitions =
|
|
{
|
|
new Transition { From = "A", To = "B" },
|
|
new Transition { From = "B", To = "A" }
|
|
}
|
|
};
|
|
|
|
var context = await ExecuteAsync(stateMachine);
|
|
|
|
Assert.Equal("B", context.GetProperty<string>(CurrentStateProperty));
|
|
Assert.Equal(ActivityStatus.Running, context.Status);
|
|
|
|
var continuation = context.WorkflowExecutionContext.Scheduler.Take();
|
|
await CompleteScheduledActivityAsync(context, continuation.Activity);
|
|
|
|
Assert.Equal("A", context.GetProperty<string>(CurrentStateProperty));
|
|
Assert.Equal(ActivityStatus.Running, context.Status);
|
|
Assert.Single(context.WorkflowExecutionContext.Scheduler.List());
|
|
}
|
|
|
|
[Fact(DisplayName = "StateMachine resumes an automatic cycle continuation after state persistence")]
|
|
public async Task AutomaticCycleContinuationSurvivesStateRoundTrip()
|
|
{
|
|
var stateMachine = new StateMachineActivity
|
|
{
|
|
InitialState = "A",
|
|
States = { new StateMachineState { Name = "A" } },
|
|
Transitions = { new Transition { From = "A", To = "A" } }
|
|
};
|
|
var fixture = CreatePersistenceFixture(stateMachine);
|
|
var context = await fixture.BuildAsync();
|
|
context.Id = "state-machine-context";
|
|
context.WorkflowExecutionContext.AddActivityExecutionContext(context);
|
|
|
|
await fixture.ExecuteAsync(context);
|
|
|
|
var sourceWorkflowContext = context.WorkflowExecutionContext;
|
|
var sourceContinuation = Assert.Single(sourceWorkflowContext.Scheduler.List());
|
|
Assert.Equal(context.Id, sourceContinuation.SchedulingActivityExecutionId);
|
|
Assert.Single(sourceWorkflowContext.CompletionCallbacks);
|
|
|
|
var extractor = sourceWorkflowContext.GetRequiredService<IWorkflowStateExtractor>();
|
|
var state = extractor.Extract(sourceWorkflowContext);
|
|
var resumedWorkflowContext = await WorkflowExecutionContext.CreateAsync(
|
|
sourceWorkflowContext.ServiceProvider,
|
|
sourceWorkflowContext.WorkflowGraph,
|
|
state.Id,
|
|
CancellationToken.None);
|
|
|
|
await extractor.ApplyAsync(resumedWorkflowContext, state);
|
|
|
|
var resumedStateMachineContext = Assert.Single(resumedWorkflowContext.ActivityExecutionContexts, x => x.Activity == stateMachine);
|
|
var resumedContinuation = resumedWorkflowContext.Scheduler.Take();
|
|
Assert.Single(state.CompletionCallbacks);
|
|
Assert.Equal(resumedStateMachineContext.Id, resumedContinuation.Owner?.Id);
|
|
Assert.Equal(resumedStateMachineContext.Id, resumedContinuation.SchedulingActivityExecutionId);
|
|
Assert.Contains(resumedWorkflowContext.CompletionCallbacks, x => x.Owner == resumedStateMachineContext && x.Child.Activity == resumedContinuation.Activity);
|
|
|
|
var resumedChildContext = await resumedWorkflowContext.CreateActivityExecutionContextAsync(resumedContinuation.Activity, new ActivityInvocationOptions
|
|
{
|
|
Owner = resumedContinuation.Owner,
|
|
Tag = resumedContinuation.Tag,
|
|
SchedulingActivityExecutionId = resumedContinuation.SchedulingActivityExecutionId
|
|
});
|
|
resumedChildContext.TransitionTo(ActivityStatus.Running);
|
|
resumedWorkflowContext.AddActivityExecutionContext(resumedChildContext);
|
|
await resumedContinuation.Activity.ExecuteAsync(resumedChildContext);
|
|
|
|
Assert.Equal("A", resumedStateMachineContext.GetProperty<string>(CurrentStateProperty));
|
|
Assert.Equal(ActivityStatus.Running, resumedStateMachineContext.Status);
|
|
Assert.Single(resumedWorkflowContext.Scheduler.List());
|
|
}
|
|
|
|
[Fact(DisplayName = "StateMachine completes a triggerless transition with a composite action")]
|
|
public async Task TriggerlessTransitionWithCompositeActionCompletes()
|
|
{
|
|
var stateMachine = new StateMachineActivity
|
|
{
|
|
InitialState = "Source",
|
|
States =
|
|
{
|
|
new StateMachineState
|
|
{
|
|
Name = "Source",
|
|
Exit = new WriteLine("exit") { Id = "source-exit" }
|
|
},
|
|
new StateMachineState { Name = "Target" }
|
|
},
|
|
Transitions =
|
|
{
|
|
new Transition
|
|
{
|
|
From = "Source",
|
|
To = "Target",
|
|
Condition = new(true),
|
|
Action = new Sequence
|
|
{
|
|
Activities =
|
|
{
|
|
new WriteLine("first action") { Id = "first-action" },
|
|
new WriteLine("second action") { Id = "second-action" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
var fixture = CreatePersistenceFixture(stateMachine);
|
|
var context = await fixture.BuildAsync();
|
|
context.Id = "state-machine-context";
|
|
context.WorkflowExecutionContext.AddActivityExecutionContext(context);
|
|
|
|
await fixture.ExecuteAsync(context);
|
|
|
|
while (context.WorkflowExecutionContext.Scheduler.HasAny)
|
|
await ExecuteNextScheduledActivityAsync(context.WorkflowExecutionContext);
|
|
|
|
Assert.Equal("Target", context.GetProperty<string>(CurrentStateProperty));
|
|
Assert.Equal(ActivityStatus.Completed, context.Status);
|
|
}
|
|
|
|
[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 cancelBookmark = cancelTriggerContext.CreateBookmark("cancel");
|
|
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);
|
|
Assert.Contains(cancelBookmark, context.WorkflowExecutionContext.Bookmarks);
|
|
}
|
|
|
|
[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);
|
|
var cancelBookmark = cancelTriggerContext.CreateBookmark("cancel");
|
|
|
|
await CompleteScheduledActivityAsync(context, _payTrigger);
|
|
|
|
Assert.Equal(ActivityStatus.Canceled, cancelTriggerContext.Status);
|
|
Assert.DoesNotContain(cancelBookmark, context.WorkflowExecutionContext.Bookmarks);
|
|
}
|
|
|
|
[Fact(DisplayName = "StateMachine cancels every distinct competing outbound trigger")]
|
|
public async Task AcceptedTransitionCancelsDistinctCompetingOutboundTriggers()
|
|
{
|
|
var thirdTrigger = new WriteLine("third trigger") { Id = "third-trigger" };
|
|
_stateMachine.Transitions.Add(new Transition
|
|
{
|
|
Name = "CloseFromNew",
|
|
From = "New",
|
|
To = "Closed",
|
|
Trigger = thirdTrigger
|
|
});
|
|
var context = await ExecuteAndEnterNewStateAsync();
|
|
var cancelTriggerContext = await CreateScheduledActivityContextAsync(context, _cancelTrigger);
|
|
var thirdTriggerContext = await CreateScheduledActivityContextAsync(context, thirdTrigger);
|
|
var cancelBookmark = cancelTriggerContext.CreateBookmark("cancel");
|
|
var thirdBookmark = thirdTriggerContext.CreateBookmark("third");
|
|
|
|
await CompleteScheduledActivityAsync(context, _payTrigger);
|
|
|
|
Assert.Equal(ActivityStatus.Canceled, cancelTriggerContext.Status);
|
|
Assert.Equal(ActivityStatus.Canceled, thirdTriggerContext.Status);
|
|
Assert.DoesNotContain(cancelBookmark, context.WorkflowExecutionContext.Bookmarks);
|
|
Assert.DoesNotContain(thirdBookmark, context.WorkflowExecutionContext.Bookmarks);
|
|
}
|
|
|
|
[Fact(DisplayName = "StateMachine rejects transitions that share a trigger instance")]
|
|
public async Task SharedTriggerInstanceIsRejected()
|
|
{
|
|
_stateMachine.Transitions.Single(x => x.Name == "Cancel").Trigger = _payTrigger;
|
|
|
|
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => ExecuteAsync());
|
|
|
|
Assert.Contains("cannot share a Trigger activity", exception.Message);
|
|
}
|
|
|
|
[Fact(DisplayName = "StateMachine rejects transition triggers with duplicate activity IDs")]
|
|
public async Task DuplicateTriggerIdIsRejected()
|
|
{
|
|
_cancelTrigger.Id = _payTrigger.Id;
|
|
|
|
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => ExecuteAsync());
|
|
|
|
Assert.Contains("unique ID", exception.Message);
|
|
}
|
|
|
|
[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<string>(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<string>(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));
|
|
}
|
|
|
|
[Fact(DisplayName = "StateMachine ignores stale state exit completions")]
|
|
public async Task IgnoresStaleStateExitCompletions()
|
|
{
|
|
var context = await ExecuteAndEnterNewStateAsync();
|
|
|
|
await CompleteScheduledActivityAsync(context, _payTrigger);
|
|
context.SetProperty(CurrentStateProperty, "Paid");
|
|
await CompleteScheduledActivityAsync(context, _newExit);
|
|
|
|
Assert.False(context.HasScheduledActivity(_payAction));
|
|
}
|
|
|
|
private async Task<ActivityExecutionContext> ExecuteAndEnterNewStateAsync()
|
|
{
|
|
var context = await ExecuteAsync();
|
|
await CompleteScheduledActivityAsync(context, _newEntry);
|
|
return context;
|
|
}
|
|
|
|
private static ActivityTestFixture CreateFixture(StateMachineActivity stateMachine) => new ActivityTestFixture(stateMachine)
|
|
.ConfigureServices(services =>
|
|
{
|
|
services.RemoveAll<IWorkflowExecutionContextSchedulerStrategy>();
|
|
services.AddSingleton<IWorkflowExecutionContextSchedulerStrategy, WorkflowExecutionContextSchedulerStrategy>();
|
|
});
|
|
|
|
private static ActivityTestFixture CreatePersistenceFixture(StateMachineActivity stateMachine) => CreateFixture(stateMachine)
|
|
.ConfigureServices(services =>
|
|
{
|
|
services.RemoveAll<IActivityExecutionContextSchedulerStrategy>();
|
|
services.AddSingleton<IActivityExecutionContextSchedulerStrategy, ActivityExecutionContextSchedulerStrategy>();
|
|
services.AddSingleton<IMediator>(_ => Substitute.For<IMediator>());
|
|
services.AddSingleton<IVariablePersistenceManager>(_ => Substitute.For<IVariablePersistenceManager>());
|
|
});
|
|
|
|
private Task<ActivityExecutionContext> ExecuteAsync(StateMachineActivity? stateMachine = null) => CreateFixture(stateMachine ?? _stateMachine).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);
|
|
ownerContext.Tag = callback!.Tag;
|
|
await callback.CompletionCallback!(new ActivityCompletedContext(ownerContext, childContext));
|
|
}
|
|
|
|
private static async Task<ActivityExecutionContext> 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 async Task ExecuteNextScheduledActivityAsync(WorkflowExecutionContext workflowContext)
|
|
{
|
|
var workItem = workflowContext.Scheduler.Take();
|
|
var childContext = await workflowContext.CreateActivityExecutionContextAsync(workItem.Activity, new ActivityInvocationOptions
|
|
{
|
|
Owner = workItem.Owner,
|
|
ExistingActivityExecutionContext = workItem.ExistingActivityExecutionContext,
|
|
Tag = workItem.Tag,
|
|
Variables = workItem.Variables,
|
|
Input = workItem.Input,
|
|
SchedulingActivityExecutionId = workItem.SchedulingActivityExecutionId,
|
|
SchedulingWorkflowInstanceId = workItem.SchedulingWorkflowInstanceId,
|
|
SchedulingCallStackDepth = workItem.SchedulingCallStackDepth
|
|
});
|
|
childContext.TransitionTo(ActivityStatus.Running);
|
|
workflowContext.AddActivityExecutionContext(childContext);
|
|
await childContext.Activity.ExecuteAsync(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;
|
|
}
|
|
}
|