Merge pull request #8010 from elsa-workflows/codex/state-machine-wf4-runtime
Align StateMachine runtime with WF4 lifecycle semantics
This commit is contained in:
commit
01db86ec21
|
|
@ -1,17 +1,40 @@
|
|||
# Quickstart: State Machine Activity
|
||||
|
||||
1. Add a `StateMachine` with `InitialState = "NewOrder"`.
|
||||
2. Add states named `NewOrder` and `Paid`.
|
||||
3. Add an entry activity to `NewOrder`.
|
||||
4. Add a transition from `NewOrder` to `Paid` with a trigger activity and optional action.
|
||||
5. Execute the workflow.
|
||||
6. Verify `NewOrder` entry runs, outbound transition triggers are scheduled, and the workflow remains active.
|
||||
7. Complete the trigger and verify `CurrentState` becomes `Paid`.
|
||||
8. Verify a false transition condition re-arms that transition trigger without canceling competing triggers.
|
||||
9. Verify entering a state with no valid outbound transitions completes the state machine.
|
||||
## Build a StateMachine
|
||||
|
||||
Validation command:
|
||||
1. Add a `StateMachine` with a non-empty `InitialState`.
|
||||
2. Add uniquely named states. Each state can have an optional `Entry` and `Exit` activity.
|
||||
3. Add transitions in declaration order. Each transition names its source and target and can contain one optional `Trigger`, `Condition`, and `Action` activity.
|
||||
4. Use `Sequence` when a slot must run more than one activity; Trigger and Action are scalar activity slots, not arrays.
|
||||
|
||||
## Execution contract
|
||||
|
||||
After the initial state's Entry completes, outbound transitions are considered in declaration order:
|
||||
|
||||
1. Triggerless transitions are evaluated immediately.
|
||||
2. A missing Condition is true.
|
||||
3. A false triggerless Condition falls through to the next transition. If eventful alternatives exist, their triggers are scheduled.
|
||||
4. When a transition is accepted, execution is source `Exit` → transition `Action` → target `Entry`.
|
||||
5. Self-transitions run the same full Exit → Action → Entry lifecycle.
|
||||
6. Entering a state with no outbound transitions completes the StateMachine.
|
||||
|
||||
If every outbound transition is triggerless and false, the StateMachine remains active without scheduling a busy loop. Reachable triggerless cycles yield through Elsa's scheduler between transitions so they do not grow the synchronous call stack.
|
||||
|
||||
## Durable and competing triggers
|
||||
|
||||
- A winning transition cancels distinct competing trigger work before source Exit begins.
|
||||
- If an eventful Trigger completes but its Condition is false, Elsa re-arms that rejected Trigger and preserves already-active competitors. This is an intentional Elsa durability deviation from WF4's cancel-all-and-reschedule behavior.
|
||||
- Inline StateMachine JSON cannot preserve WF4 shared Trigger object identity. Elsa 3.8 rejects shared Trigger instances and duplicate trigger activity IDs explicitly instead of guessing identity from IDs.
|
||||
- Continuation tags use declaration-order transition identity. Reordering transitions while an instance is suspended is a compatibility-sensitive definition change.
|
||||
|
||||
These boundaries are part of the accepted StateMachine semantics ADR and are observable through validation and regression tests.
|
||||
|
||||
## Validation
|
||||
|
||||
Run the focused StateMachine tests:
|
||||
|
||||
```bash
|
||||
dotnet test test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj --no-restore
|
||||
dotnet test test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj --no-restore --filter FullyQualifiedName~StateMachine
|
||||
```
|
||||
|
||||
The suite covers triggerless and eventful paths, missing and false conditions, lifecycle ordering, self-transitions, competing-trigger cancellation, identity rejection, persistence/resumption, and automatic-cycle scheduler yielding.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Runtime.CompilerServices;
|
|||
using System.Text.Json.Serialization;
|
||||
using Elsa.Expressions.Contracts;
|
||||
using Elsa.Extensions;
|
||||
using Elsa.Workflows.Activities;
|
||||
using Elsa.Workflows.Activities.StateMachine.Models;
|
||||
using Elsa.Workflows.Attributes;
|
||||
using Elsa.Workflows.Models;
|
||||
|
|
@ -19,7 +20,9 @@ namespace Elsa.Workflows.Activities.StateMachine.Activities;
|
|||
public class StateMachine : Activity
|
||||
{
|
||||
private const string PhaseEntering = "Entering";
|
||||
private const string PhaseContinuing = "Continuing";
|
||||
private const string CurrentStateProperty = "CurrentState";
|
||||
private readonly Inline _automaticTransitionContinuation = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public StateMachine([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line)
|
||||
|
|
@ -51,15 +54,13 @@ public class StateMachine : Activity
|
|||
/// </summary>
|
||||
[JsonIgnore]
|
||||
[Browsable(false)]
|
||||
public IEnumerable<IActivity> Activities =>
|
||||
States.SelectMany(x => new[] { x.Entry, x.Exit })
|
||||
.Concat(Transitions.SelectMany(x => new[] { x.Trigger, x.Action }))
|
||||
.Where(x => x != null)
|
||||
.Cast<IActivity>();
|
||||
public IEnumerable<IActivity> Activities => GetActivities();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
|
||||
{
|
||||
EnsureSupportedTriggerIdentities();
|
||||
|
||||
var currentState = GetCurrentState(context);
|
||||
SetCurrentState(context, string.IsNullOrWhiteSpace(currentState) ? InitialState : currentState);
|
||||
|
||||
|
|
@ -98,7 +99,7 @@ public class StateMachine : Activity
|
|||
|
||||
private async ValueTask ScheduleOutboundTriggersAsync(ActivityExecutionContext context, ActivityExecutionContext? schedulingContext = null)
|
||||
{
|
||||
var outboundTransitions = GetOutboundTransitions(GetCurrentState(context)).Where(x => x.Trigger != null && FindState(x.To) != null).ToList();
|
||||
var outboundTransitions = GetOutboundTransitions(GetCurrentState(context)).Where(x => FindState(x.To) != null).ToList();
|
||||
|
||||
if (!outboundTransitions.Any())
|
||||
{
|
||||
|
|
@ -106,48 +107,74 @@ public class StateMachine : Activity
|
|||
return;
|
||||
}
|
||||
|
||||
foreach (var transition in outboundTransitions)
|
||||
foreach (var transition in outboundTransitions.Where(x => x.Trigger == null))
|
||||
{
|
||||
if (await TryTakeTransitionAsync(context, transition, schedulingContext))
|
||||
return;
|
||||
}
|
||||
|
||||
var triggeredTransitions = outboundTransitions.Where(x => x.Trigger != null).ToList();
|
||||
|
||||
if (!triggeredTransitions.Any())
|
||||
return;
|
||||
|
||||
foreach (var transition in triggeredTransitions)
|
||||
await ScheduleAsync(context, transition.Trigger!, OnTriggerCompletedAsync, GetTransitionKey(transition), schedulingContext);
|
||||
}
|
||||
|
||||
private async ValueTask OnTriggerCompletedAsync(ActivityCompletedContext context)
|
||||
{
|
||||
var targetContext = context.TargetContext;
|
||||
var transition = FindTransitionByKey(targetContext, context.ChildContext.Tag as string) ?? FindTransitionByTrigger(targetContext, context.ChildContext.Activity);
|
||||
var transition = FindTransitionByKey(targetContext, GetCompletionTag(context)) ?? FindTransitionByTrigger(targetContext, context.ChildContext.Activity);
|
||||
|
||||
if (transition == null || !IsCurrentSource(targetContext, transition) || FindState(transition.To) == null)
|
||||
return;
|
||||
|
||||
var canTransition = transition.Condition == null || await EvaluateConditionAsync(targetContext, transition.Condition);
|
||||
|
||||
if (!canTransition)
|
||||
if (!await TryTakeTransitionAsync(targetContext, transition, context.ChildContext))
|
||||
{
|
||||
if (transition.Trigger != null)
|
||||
await ScheduleAsync(targetContext, transition.Trigger, OnTriggerCompletedAsync, GetTransitionKey(transition), context.ChildContext);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await CancelCompetingTriggersAsync(targetContext, transition, context.ChildContext);
|
||||
private async ValueTask<bool> TryTakeTransitionAsync(
|
||||
ActivityExecutionContext context,
|
||||
Transition transition,
|
||||
ActivityExecutionContext? schedulingContext)
|
||||
{
|
||||
var canTransition = transition.Condition == null || await EvaluateConditionAsync(context, transition.Condition);
|
||||
|
||||
if (!canTransition)
|
||||
return false;
|
||||
|
||||
await CancelCompetingTriggersAsync(context, transition, schedulingContext);
|
||||
await ExitStateAsync(context, transition, schedulingContext);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async ValueTask RunTransitionActionAsync(ActivityExecutionContext context, Transition transition, ActivityExecutionContext? schedulingContext = null)
|
||||
{
|
||||
if (!IsCurrentSource(context, transition))
|
||||
return;
|
||||
|
||||
if (transition.Action != null)
|
||||
{
|
||||
await ScheduleTransitionActivityAsync(targetContext, transition.Action, transition, OnTransitionActionCompletedAsync, context.ChildContext);
|
||||
await ScheduleTransitionActivityAsync(context, transition.Action, transition, OnTransitionActionCompletedAsync, schedulingContext);
|
||||
return;
|
||||
}
|
||||
|
||||
await ExitStateAsync(targetContext, transition, context.ChildContext);
|
||||
await CompleteTransitionAsync(context, transition, schedulingContext);
|
||||
}
|
||||
|
||||
private async ValueTask OnTransitionActionCompletedAsync(ActivityCompletedContext context)
|
||||
{
|
||||
var targetContext = context.TargetContext;
|
||||
var transition = FindTransitionByKey(targetContext, context.ChildContext.Tag as string);
|
||||
var transition = FindTransitionByKey(targetContext, GetCompletionTag(context));
|
||||
|
||||
if (transition == null || !IsCurrentSource(targetContext, transition))
|
||||
return;
|
||||
|
||||
await ExitStateAsync(targetContext, transition, context.ChildContext);
|
||||
await CompleteTransitionAsync(targetContext, transition, context.ChildContext);
|
||||
}
|
||||
|
||||
private async ValueTask ExitStateAsync(ActivityExecutionContext context, Transition transition, ActivityExecutionContext? schedulingContext = null)
|
||||
|
|
@ -160,26 +187,42 @@ public class StateMachine : Activity
|
|||
return;
|
||||
}
|
||||
|
||||
await CompleteTransitionAsync(context, transition, schedulingContext);
|
||||
await RunTransitionActionAsync(context, transition, schedulingContext);
|
||||
}
|
||||
|
||||
private async ValueTask OnStateExitCompletedAsync(ActivityCompletedContext context)
|
||||
{
|
||||
var targetContext = context.TargetContext;
|
||||
var transition = FindTransitionByKey(targetContext, context.ChildContext.Tag as string);
|
||||
var transition = FindTransitionByKey(targetContext, GetCompletionTag(context));
|
||||
|
||||
if (transition == null || !IsCurrentSource(targetContext, transition))
|
||||
return;
|
||||
|
||||
await CompleteTransitionAsync(targetContext, transition, context.ChildContext);
|
||||
await RunTransitionActionAsync(targetContext, transition, context.ChildContext);
|
||||
}
|
||||
|
||||
private async ValueTask CompleteTransitionAsync(ActivityExecutionContext context, Transition transition, ActivityExecutionContext? schedulingContext = null)
|
||||
{
|
||||
SetCurrentState(context, transition.To);
|
||||
|
||||
// Automatic transitions are normally evaluated inline after entering a state. If the
|
||||
// target is part of a triggerless cycle, queue the next evaluation instead. This keeps
|
||||
// the WF4 ordering (the transition has completed and the target state is current) while
|
||||
// allowing the workflow scheduler to unwind the current call stack between iterations.
|
||||
if (HasAutomaticCycle(GetCurrentState(context)))
|
||||
{
|
||||
await ScheduleAsync(context, _automaticTransitionContinuation, OnAutomaticTransitionContinuationCompletedAsync, PhaseContinuing, schedulingContext);
|
||||
return;
|
||||
}
|
||||
|
||||
await EnterStateAsync(context, schedulingContext);
|
||||
}
|
||||
|
||||
private async ValueTask OnAutomaticTransitionContinuationCompletedAsync(ActivityCompletedContext context)
|
||||
{
|
||||
await EnterStateAsync(context.TargetContext, context.ChildContext);
|
||||
}
|
||||
|
||||
private async ValueTask ScheduleTransitionActivityAsync(
|
||||
ActivityExecutionContext context,
|
||||
IActivity activity,
|
||||
|
|
@ -207,7 +250,7 @@ public class StateMachine : Activity
|
|||
await context.ScheduleActivityAsync(activity, options);
|
||||
}
|
||||
|
||||
private async Task CancelCompetingTriggersAsync(ActivityExecutionContext context, Transition winningTransition, ActivityExecutionContext winningTriggerContext)
|
||||
private async Task CancelCompetingTriggersAsync(ActivityExecutionContext context, Transition winningTransition, ActivityExecutionContext? winningTriggerContext)
|
||||
{
|
||||
var competingTriggerIds = GetOutboundTransitions(winningTransition.From)
|
||||
.Where(x => !ReferenceEquals(x, winningTransition))
|
||||
|
|
@ -255,6 +298,43 @@ public class StateMachine : Activity
|
|||
|
||||
private string? GetCurrentState(ActivityExecutionContext context) => context.GetProperty<string>(CurrentStateProperty) ?? CurrentState;
|
||||
|
||||
private IEnumerable<IActivity> GetActivities()
|
||||
{
|
||||
foreach (var stateActivity in States.SelectMany(x => new[] { x.Entry, x.Exit }).Where(x => x != null))
|
||||
yield return stateActivity!;
|
||||
|
||||
var seenTriggerInstances = new HashSet<IActivity>(ReferenceEqualityComparer.Instance);
|
||||
var seenTriggerIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var transition in Transitions)
|
||||
{
|
||||
if (transition.Trigger != null && seenTriggerInstances.Add(transition.Trigger) &&
|
||||
(string.IsNullOrWhiteSpace(transition.Trigger.Id) || seenTriggerIds.Add(transition.Trigger.Id)))
|
||||
yield return transition.Trigger;
|
||||
|
||||
if (transition.Action != null)
|
||||
yield return transition.Action;
|
||||
}
|
||||
|
||||
yield return _automaticTransitionContinuation;
|
||||
}
|
||||
|
||||
private void EnsureSupportedTriggerIdentities()
|
||||
{
|
||||
var triggers = Transitions.Where(x => x.Trigger != null).Select(x => x.Trigger!).ToList();
|
||||
var seenInstances = new HashSet<IActivity>(ReferenceEqualityComparer.Instance);
|
||||
var seenIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var trigger in triggers)
|
||||
{
|
||||
var sharesInstance = !seenInstances.Add(trigger);
|
||||
var duplicatesId = !string.IsNullOrWhiteSpace(trigger.Id) && !seenIds.Add(trigger.Id);
|
||||
|
||||
if (sharesInstance || duplicatesId)
|
||||
throw new InvalidOperationException("StateMachine transitions cannot share a Trigger activity in Elsa 3.8. Give each transition its own trigger activity with a unique ID.");
|
||||
}
|
||||
}
|
||||
|
||||
private void SetCurrentState(ActivityExecutionContext context, string? state)
|
||||
{
|
||||
CurrentState = state;
|
||||
|
|
@ -283,6 +363,29 @@ public class StateMachine : Activity
|
|||
? []
|
||||
: Transitions.Where(x => string.Equals(x.From, sourceState, StringComparison.Ordinal));
|
||||
|
||||
private bool HasAutomaticCycle(string? startingState)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(startingState))
|
||||
return false;
|
||||
|
||||
var visitedStates = new HashSet<string>(StringComparer.Ordinal);
|
||||
var statesToVisit = new Stack<string>([startingState]);
|
||||
|
||||
while (statesToVisit.TryPop(out var state))
|
||||
{
|
||||
foreach (var transition in GetOutboundTransitions(state).Where(x => x.Trigger == null && FindState(x.To) != null))
|
||||
{
|
||||
if (string.Equals(transition.To, startingState, StringComparison.Ordinal))
|
||||
return true;
|
||||
|
||||
if (visitedStates.Add(transition.To!))
|
||||
statesToVisit.Push(transition.To!);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private Transition? FindTransitionByTrigger(ActivityExecutionContext context, IActivity trigger) =>
|
||||
GetOutboundTransitions(GetCurrentState(context)).FirstOrDefault(x => ReferenceEquals(x.Trigger, trigger));
|
||||
|
||||
|
|
@ -314,4 +417,8 @@ public class StateMachine : Activity
|
|||
string.IsNullOrWhiteSpace(transition.Name)
|
||||
? $"{transition.From}->{transition.To}"
|
||||
: transition.Name;
|
||||
|
||||
private static string? GetCompletionTag(ActivityCompletedContext context) =>
|
||||
context.TargetContext.Tag as string ?? context.ChildContext.Tag as string;
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -256,7 +256,16 @@ public class WorkflowStateExtractor(ILogger<WorkflowStateExtractor> logger) : IW
|
|||
var variables = activityWorkItemState.Variables;
|
||||
var input = activityWorkItemState.Input;
|
||||
var tag = activityWorkItemState.Tag;
|
||||
var workItem = new ActivityWorkItem(activity, ownerContext, tag, variables, existingActivityExecutionContext, input);
|
||||
var workItem = new ActivityWorkItem(
|
||||
activity,
|
||||
ownerContext,
|
||||
tag,
|
||||
variables,
|
||||
existingActivityExecutionContext,
|
||||
input,
|
||||
activityWorkItemState.SchedulingActivityExecutionId,
|
||||
activityWorkItemState.SchedulingWorkflowInstanceId,
|
||||
activityWorkItemState.SchedulingCallStackDepth);
|
||||
workflowExecutionContext.Scheduler.Schedule(workItem);
|
||||
}
|
||||
}
|
||||
|
|
@ -329,6 +338,9 @@ public class WorkflowStateExtractor(ILogger<WorkflowStateExtractor> logger) : IW
|
|||
Variables = x.Variables?.ToList(),
|
||||
ExistingActivityExecutionContextId = x.ExistingActivityExecutionContext?.Id,
|
||||
Input = x.Input,
|
||||
SchedulingActivityExecutionId = x.SchedulingActivityExecutionId,
|
||||
SchedulingWorkflowInstanceId = x.SchedulingWorkflowInstanceId,
|
||||
SchedulingCallStackDepth = x.SchedulingCallStackDepth,
|
||||
});
|
||||
|
||||
state.ScheduledActivities = scheduledActivities.ToList();
|
||||
|
|
|
|||
|
|
@ -36,4 +36,19 @@ public class ActivityWorkItemState
|
|||
/// Optional input to pass to the activity.
|
||||
/// </summary>
|
||||
public IDictionary<string, object> Input { get; set; } = new Dictionary<string, object>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ID of the activity execution context that scheduled this work item.
|
||||
/// </summary>
|
||||
public string? SchedulingActivityExecutionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The workflow instance ID of the workflow that scheduled this work item, if it crossed a workflow boundary.
|
||||
/// </summary>
|
||||
public string? SchedulingWorkflowInstanceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The call stack depth of the activity execution context that scheduled this work item.
|
||||
/// </summary>
|
||||
public int? SchedulingCallStackDepth { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
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;
|
||||
|
|
@ -83,18 +85,19 @@ public class StateMachineTests
|
|||
Assert.Equal(ActivityStatus.Running, context.Status);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "StateMachine executes accepted transition action, source exit, target entry and target triggers")]
|
||||
[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.True(context.HasScheduledActivity(_newExit));
|
||||
|
||||
await CompleteScheduledActivityAsync(context, _newExit);
|
||||
Assert.Equal("Paid", _stateMachine.CurrentState);
|
||||
Assert.Equal("Paid", context.GetProperty<string>(CurrentStateProperty));
|
||||
Assert.True(context.HasScheduledActivity(_paidEntry));
|
||||
|
|
@ -104,6 +107,40 @@ public class StateMachineTests
|
|||
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()
|
||||
{
|
||||
|
|
@ -120,12 +157,285 @@ public class StateMachineTests
|
|||
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);
|
||||
|
|
@ -134,6 +444,7 @@ public class StateMachineTests
|
|||
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")]
|
||||
|
|
@ -141,10 +452,57 @@ public class StateMachineTests
|
|||
{
|
||||
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")]
|
||||
|
|
@ -242,10 +600,23 @@ public class StateMachineTests
|
|||
var context = await ExecuteAndEnterNewStateAsync();
|
||||
|
||||
await CompleteScheduledActivityAsync(context, _payTrigger);
|
||||
await CompleteScheduledActivityAsync(context, _newExit);
|
||||
context.SetProperty(CurrentStateProperty, "Paid");
|
||||
await CompleteScheduledActivityAsync(context, _payAction);
|
||||
|
||||
Assert.False(context.HasScheduledActivity(_newExit));
|
||||
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()
|
||||
|
|
@ -255,13 +626,23 @@ public class StateMachineTests
|
|||
return context;
|
||||
}
|
||||
|
||||
private Task<ActivityExecutionContext> ExecuteAsync(StateMachineActivity? stateMachine = null) => new ActivityTestFixture(stateMachine ?? _stateMachine)
|
||||
private static ActivityTestFixture CreateFixture(StateMachineActivity stateMachine) => new ActivityTestFixture(stateMachine)
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IWorkflowExecutionContextSchedulerStrategy>();
|
||||
services.AddSingleton<IWorkflowExecutionContextSchedulerStrategy, WorkflowExecutionContextSchedulerStrategy>();
|
||||
})
|
||||
.ExecuteAsync();
|
||||
});
|
||||
|
||||
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)
|
||||
{
|
||||
|
|
@ -269,7 +650,8 @@ public class StateMachineTests
|
|||
var callback = PopCallback(ownerContext, activity);
|
||||
|
||||
Assert.NotNull(callback?.CompletionCallback);
|
||||
await callback!.CompletionCallback!(new ActivityCompletedContext(ownerContext, childContext));
|
||||
ownerContext.Tag = callback!.Tag;
|
||||
await callback.CompletionCallback!(new ActivityCompletedContext(ownerContext, childContext));
|
||||
}
|
||||
|
||||
private static async Task<ActivityExecutionContext> CreateScheduledActivityContextAsync(ActivityExecutionContext ownerContext, IActivity activity)
|
||||
|
|
@ -285,6 +667,25 @@ public class StateMachineTests
|
|||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
using Elsa.Common;
|
||||
using Elsa.Testing.Shared;
|
||||
using Elsa.Workflows.Activities;
|
||||
using Elsa.Workflows.Models;
|
||||
using Elsa.Workflows.Options;
|
||||
using Elsa.Workflows.Services;
|
||||
using Elsa.Workflows.State;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Xunit;
|
||||
|
||||
|
|
@ -12,6 +14,46 @@ namespace Elsa.Workflows.Core.UnitTests.Services;
|
|||
|
||||
public class WorkflowStateExtractorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Extract_And_Apply_PreservesScheduledActivityMetadata()
|
||||
{
|
||||
var root = new WriteLine("root");
|
||||
var fixture = new ActivityTestFixture(root).ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IWorkflowExecutionContextSchedulerStrategy>();
|
||||
services.AddSingleton<IWorkflowExecutionContextSchedulerStrategy, WorkflowExecutionContextSchedulerStrategy>();
|
||||
});
|
||||
var context = await fixture.BuildAsync();
|
||||
context.Id = "owner-context";
|
||||
context.WorkflowExecutionContext.AddActivityExecutionContext(context);
|
||||
var scheduledWorkItem = new ActivityWorkItem(
|
||||
root,
|
||||
context,
|
||||
tag: "scheduled",
|
||||
schedulingActivityExecutionId: "predecessor",
|
||||
schedulingWorkflowInstanceId: "parent-workflow",
|
||||
schedulingCallStackDepth: 3);
|
||||
context.WorkflowExecutionContext.Scheduler.Schedule(scheduledWorkItem);
|
||||
|
||||
var sourceWorkflowContext = context.WorkflowExecutionContext;
|
||||
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 restoredWorkItem = Assert.Single(resumedWorkflowContext.Scheduler.List());
|
||||
Assert.Equal("owner-context", restoredWorkItem.Owner?.Id);
|
||||
Assert.Equal("scheduled", restoredWorkItem.Tag);
|
||||
Assert.Equal("predecessor", restoredWorkItem.SchedulingActivityExecutionId);
|
||||
Assert.Equal("parent-workflow", restoredWorkItem.SchedulingWorkflowInstanceId);
|
||||
Assert.Equal(3, restoredWorkItem.SchedulingCallStackDepth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Extract_And_Apply_PreservesCallStackDepth()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue