diff --git a/src/common/Elsa.Testing.Shared.Integration/RunActivityExtensions.cs b/src/common/Elsa.Testing.Shared.Integration/RunActivityExtensions.cs index 4efb894ef..37e12a909 100644 --- a/src/common/Elsa.Testing.Shared.Integration/RunActivityExtensions.cs +++ b/src/common/Elsa.Testing.Shared.Integration/RunActivityExtensions.cs @@ -10,36 +10,41 @@ namespace Elsa.Testing.Shared; /// Provides extension methods for . /// [PublicAPI] +[Obsolete("Use WorkflowTestFixture instead.")] public static class RunActivityExtensions { - /// - /// Runs the specified activity. - /// /// The service provider. - /// The activity to run. - /// An optional cancellation token. - /// The result of running the activity. - public static async Task RunActivityAsync(this IServiceProvider services, IActivity activity, CancellationToken cancellationToken = default) + extension(IServiceProvider services) { - await services.PopulateRegistriesAsync(); - var workflowRunner = services.GetRequiredService(); - var result = await workflowRunner.RunAsync(activity, cancellationToken: cancellationToken); - return result; - } + /// + /// Runs the specified activity. + /// + /// The activity to run. + /// An optional cancellation token. + /// The result of running the activity. + [Obsolete("Use WorkflowTestFixture instead.")] + public async Task RunActivityAsync(IActivity activity, CancellationToken cancellationToken = default) + { + await services.PopulateRegistriesAsync(); + var workflowRunner = services.GetRequiredService(); + var result = await workflowRunner.RunAsync(activity, cancellationToken: cancellationToken); + return result; + } - /// - /// Runs the specified activity. - /// - /// The service provider. - /// The activity to run. - /// An set of options. - /// An optional cancellation token. - /// The result of running the activity. - public static async Task RunActivityAsync(this IServiceProvider services, IActivity activity, RunWorkflowOptions options, CancellationToken cancellationToken = default) - { - await services.PopulateRegistriesAsync(); - var workflowRunner = services.GetRequiredService(); - var result = await workflowRunner.RunAsync(activity, options, cancellationToken); - return result; + /// + /// Runs the specified activity. + /// + /// The activity to run. + /// A set of options. + /// An optional cancellation token. + /// The result of running the activity. + [Obsolete("Use WorkflowTestFixture instead.")] + public async Task RunActivityAsync(IActivity activity, RunWorkflowOptions options, CancellationToken cancellationToken = default) + { + await services.PopulateRegistriesAsync(); + var workflowRunner = services.GetRequiredService(); + var result = await workflowRunner.RunAsync(activity, options, cancellationToken); + return result; + } } } \ No newline at end of file diff --git a/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs b/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs index 5fdb20243..e86856189 100644 --- a/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs +++ b/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs @@ -1,12 +1,9 @@ using Elsa.Common.Models; using Elsa.Workflows; using Elsa.Workflows.Management; -using Elsa.Workflows.Management.Entities; -using Elsa.Workflows.Management.Models; using Elsa.Workflows.Models; using Elsa.Workflows.Options; using Elsa.Workflows.Runtime; -using Elsa.Workflows.Runtime.Filters; using Elsa.Workflows.Runtime.Messages; using Elsa.Workflows.State; using JetBrains.Annotations; diff --git a/src/common/Elsa.Testing.Shared.Integration/RunWorkflowResultAssertions.cs b/src/common/Elsa.Testing.Shared.Integration/RunWorkflowResultAssertions.cs new file mode 100644 index 000000000..5f8fcb174 --- /dev/null +++ b/src/common/Elsa.Testing.Shared.Integration/RunWorkflowResultAssertions.cs @@ -0,0 +1,122 @@ +using Elsa.Workflows; +using Elsa.Workflows.Models; +using JetBrains.Annotations; +using Xunit; + +namespace Elsa.Testing.Shared; + +/// +/// Provides assertion methods for to facilitate Journal-based testing. +/// These methods integrate with xUnit's assertion framework. +/// +[PublicAPI] +public static class RunWorkflowResultAssertions +{ + /// The workflow result. + extension(RunWorkflowResult result) + { + /// + /// Asserts that the specified activity was executed (present in the journal). + /// + /// The activity that should have been executed. + public void AssertActivityExecuted(IActivity activity) + { + var context = result.GetActivityContext(activity); + Assert.NotNull(context); + } + + /// + /// Asserts that the specified activity was not executed (not present in the journal). + /// + /// The activity that should not have been executed. + public void AssertActivityNotExecuted(IActivity activity) + { + var context = result.GetActivityContext(activity); + Assert.Null(context); + } + + /// + /// Asserts that the specified activity completed successfully. + /// + /// The activity that should have completed. + public void AssertActivityCompleted(IActivity activity) + { + var context = result.GetActivityContext(activity); + Assert.NotNull(context); + Assert.Equal(ActivityStatus.Completed, context.Status); + } + + /// + /// Asserts that the specified activity has the expected status. + /// + /// The activity to check. + /// The expected status. + public void AssertActivityStatus(IActivity activity, ActivityStatus expectedStatus) + { + var context = result.GetActivityContext(activity); + Assert.NotNull(context); + Assert.Equal(expectedStatus, context.Status); + } + + /// + /// Asserts that all specified activities were executed. + /// + /// The activities that should have been executed. + public void AssertActivitiesExecuted(params IActivity[] activities) + { + foreach (var activity in activities) + result.AssertActivityExecuted(activity); + } + + /// + /// Asserts that all specified activities were not executed. + /// + /// The activities that should not have been executed. + public void AssertActivitiesNotExecuted(params IActivity[] activities) + { + foreach (var activity in activities) + result.AssertActivityNotExecuted(activity); + } + + /// + /// Asserts that all specified activities completed successfully. + /// + /// The activities that should have completed. + public void AssertActivitiesCompleted(params IActivity[] activities) + { + foreach (var activity in activities) + result.AssertActivityCompleted(activity); + } + + /// + /// Asserts that the specified activity was executed exactly the expected number of times. + /// + /// The activity to check. + /// The expected execution count. + public void AssertActivityExecutionCount(IActivity activity, int expectedCount) + { + var actualCount = result.GetExecutionCount(activity); + Assert.Equal(expectedCount, actualCount); + } + + /// + /// Asserts that the workflow completed successfully. + /// + public void AssertWorkflowCompleted() + { + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus); + } + + /// + /// Asserts that the workflow has the expected status and substatus. + /// + /// The expected workflow status. + /// The expected workflow substatus. + public void AssertWorkflowStatus(WorkflowStatus expectedStatus, WorkflowSubStatus expectedSubStatus) + { + Assert.Equal(expectedStatus, result.WorkflowState.Status); + Assert.Equal(expectedSubStatus, result.WorkflowState.SubStatus); + } + } +} diff --git a/src/common/Elsa.Testing.Shared.Integration/RunWorkflowResultExtensions.cs b/src/common/Elsa.Testing.Shared.Integration/RunWorkflowResultExtensions.cs new file mode 100644 index 000000000..10d47fec4 --- /dev/null +++ b/src/common/Elsa.Testing.Shared.Integration/RunWorkflowResultExtensions.cs @@ -0,0 +1,72 @@ +using Elsa.Workflows; +using Elsa.Workflows.Models; +using JetBrains.Annotations; + +namespace Elsa.Testing.Shared; + +/// +/// Provides extension methods for to facilitate testing with Journal-based assertions. +/// +[PublicAPI] +public static class RunWorkflowResultExtensions +{ + /// The workflow result. + extension(RunWorkflowResult result) + { + /// + /// Gets the activity execution context for the specified activity from the workflow result's journal. + /// + /// The activity to find. + /// The activity execution context, or null if not found. + public ActivityExecutionContext? GetActivityContext(IActivity activity) => result.Journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity == activity); + + /// + /// Checks if the specified activity was executed (present in the journal). + /// + /// The activity to check. + /// True if the activity was executed, false otherwise. + public bool WasExecuted(IActivity activity) => result.GetActivityContext(activity) != null; + + /// + /// Checks if the specified activity completed successfully. + /// + /// The activity to check. + /// True if the activity completed, false otherwise. + public bool WasCompleted(IActivity activity) + { + var context = result.GetActivityContext(activity); + return context?.Status == ActivityStatus.Completed; + } + + /// + /// Gets the execution status of the specified activity. + /// + /// The activity to check. + /// The activity status, or null if the activity was not executed. + public ActivityStatus? GetActivityStatus(IActivity activity) => result.GetActivityContext(activity)?.Status; + + /// + /// Gets all executed activities from the workflow result's journal. + /// + /// Collection of executed activities. + public IEnumerable GetExecutedActivities() => result.Journal.ActivityExecutionContexts.Select(x => x.Activity); + + /// + /// Gets all completed activities from the workflow result's journal. + /// + /// Collection of completed activities. + public IEnumerable GetCompletedActivities() => + result.Journal.ActivityExecutionContexts + .Where(x => x.Status == ActivityStatus.Completed) + .Select(x => x.Activity); + + /// + /// Counts how many times the specified activity was executed. + /// Useful for activities that may execute multiple times (e.g., in loops). + /// + /// The activity to count. + /// The number of times the activity was executed. + public int GetExecutionCount(IActivity activity) => + result.Journal.ActivityExecutionContexts.Count(x => x.Activity == activity); + } +} diff --git a/src/common/Elsa.Testing.Shared.Integration/ServiceProviderExtensions.cs b/src/common/Elsa.Testing.Shared.Integration/ServiceProviderExtensions.cs index 18cbcfd79..1ef8bbaa5 100644 --- a/src/common/Elsa.Testing.Shared.Integration/ServiceProviderExtensions.cs +++ b/src/common/Elsa.Testing.Shared.Integration/ServiceProviderExtensions.cs @@ -3,12 +3,7 @@ using Elsa.Workflows; using Elsa.Workflows.Management; using Elsa.Workflows.Management.Entities; using Elsa.Workflows.Management.Models; -using Elsa.Workflows.Models; -using Elsa.Workflows.Options; using Elsa.Workflows.Runtime; -using Elsa.Workflows.Runtime.Filters; -using Elsa.Workflows.Runtime.Messages; -using Elsa.Workflows.State; using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; diff --git a/src/modules/Elsa.Workflows.Core/Activities/Switch.cs b/src/modules/Elsa.Workflows.Core/Activities/Switch.cs index d40c0fb53..1b94436d6 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Switch.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Switch.cs @@ -56,19 +56,37 @@ public class Switch : Activity var matchingCases = (await FindMatchingCasesAsync(context.ExpressionExecutionContext)).ToList(); var hasAnyMatches = matchingCases.Any(); var mode = context.Get(Mode); - var results = mode == SwitchMode.MatchFirst ? hasAnyMatches ? new[] { matchingCases.First() } : Array.Empty() : matchingCases.ToArray(); + var results = mode == SwitchMode.MatchFirst + ? hasAnyMatches + ? new[] { matchingCases.First() } + : Array.Empty() + : matchingCases.ToArray(); + + var scheduledActivityIds = new HashSet(); if (hasAnyMatches) { foreach (var result in results) { - await context.ScheduleActivityAsync(result.Activity, OnChildActivityCompletedAsync); - } + if (result.Activity == null) + continue; - return; + await context.ScheduleActivityAsync(result.Activity, OnChildActivityCompletedAsync); + scheduledActivityIds.Add(result.Activity.Id); + } + } + else if (Default != null) + { + await context.ScheduleActivityAsync(Default, OnChildActivityCompletedAsync); + scheduledActivityIds.Add(Default.Id); } - await context.ScheduleActivityAsync(Default, OnChildActivityCompletedAsync); + context.SetProperty("ScheduledActivityIds", scheduledActivityIds); + + if (!scheduledActivityIds.Any()) + { + await context.CompleteActivityAsync(); + } } private async Task> FindMatchingCasesAsync(ExpressionExecutionContext context) @@ -89,9 +107,19 @@ public class Switch : Activity return matchingCases; } + /// + /// Tracks the completion of scheduled child activities and completes the Switch activity only after all scheduled children have finished executing. + /// private async ValueTask OnChildActivityCompletedAsync(ActivityCompletedContext context) { - await context.TargetContext.CompleteActivityAsync(); + var scheduledActivityIds = context.TargetContext.GetProperty>("ScheduledActivityIds"); + + if (scheduledActivityIds != null + && scheduledActivityIds.Remove(context.ChildContext.Activity.Id) + && scheduledActivityIds.Count == 0) + { + await context.TargetContext.CompleteActivityAsync(); + } } } diff --git a/test/integration/Elsa.Activities.IntegrationTests/Branching/SwitchTests.cs b/test/integration/Elsa.Activities.IntegrationTests/Branching/SwitchTests.cs new file mode 100644 index 000000000..891e53089 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Branching/SwitchTests.cs @@ -0,0 +1,288 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Xunit.Abstractions; + +namespace Elsa.Activities.IntegrationTests.Branching; + +/// +/// Integration tests for Switch activity. +/// +public class SwitchTests(ITestOutputHelper testOutputHelper) +{ + private readonly WorkflowTestFixture _fixture = new(testOutputHelper); + + [Fact(DisplayName = "Switch completes immediately when no matches and no activities scheduled")] + public async Task Switch_CompletesImmediately_WhenNoMatchesAndNoActivitiesScheduled() + { + // Arrange + var case1Activity = new WriteLine("Case 1"); + var case2Activity = new WriteLine("Case 2"); + + var switchActivity = new Switch + { + Cases = + { + new("Case 1", () => false, case1Activity), + new("Case 2", () => false, case2Activity) + }, + Default = null + }; + + // Act + var result = await _fixture.RunActivityAsync(switchActivity); + + // Assert + result.AssertActivityCompleted(switchActivity); + result.AssertActivitiesNotExecuted(case1Activity, case2Activity); + } + + [Fact(DisplayName = "Switch completes immediately when no matches but has null default")] + public async Task Switch_CompletesImmediately_WhenNoMatchesAndDefaultIsNull() + { + // Arrange + var case1Activity = new WriteLine("Case 1"); + + var switchActivity = new Switch + { + Cases = + { + new("Case 1", () => false, case1Activity) + } + }; + + // Act + var result = await _fixture.RunActivityAsync(switchActivity); + + // Assert + result.AssertActivityCompleted(switchActivity); + result.AssertActivityNotExecuted(case1Activity); + } + + [Fact(DisplayName = "Switch schedules default activity when no cases match")] + public async Task Switch_SchedulesDefaultActivity_WhenNoCasesMatch() + { + // Arrange + var case1Activity = new WriteLine("Case 1"); + var case2Activity = new WriteLine("Case 2"); + var defaultActivity = new WriteLine("Default"); + + var switchActivity = new Switch + { + Cases = + { + new("Case 1", () => false, case1Activity), + new("Case 2", () => false, case2Activity) + }, + Default = defaultActivity + }; + + // Act + var result = await _fixture.RunActivityAsync(switchActivity); + + // Assert + result.AssertActivitiesCompleted(switchActivity, defaultActivity); + result.AssertActivitiesNotExecuted(case1Activity, case2Activity); + } + + [Fact(DisplayName = "Switch in MatchFirst mode schedules only first matching case")] + public async Task Switch_InMatchFirstMode_SchedulesOnlyFirstMatchingCase() + { + // Arrange + var case1Activity = new WriteLine("Case 1"); + var case2Activity = new WriteLine("Case 2"); + var case3Activity = new WriteLine("Case 3"); + + var switchActivity = new Switch + { + Mode = new(SwitchMode.MatchFirst), + Cases = + { + new("Case 1", () => false, case1Activity), + new("Case 2", () => true, case2Activity), + new("Case 3", () => true, case3Activity) + } + }; + + // Act + var result = await _fixture.RunActivityAsync(switchActivity); + + // Assert + result.AssertActivitiesCompleted(switchActivity, case2Activity); + result.AssertActivitiesNotExecuted(case1Activity, case3Activity); + } + + [Fact(DisplayName = "Switch in MatchAny mode schedules all matching cases")] + public async Task Switch_InMatchAnyMode_SchedulesAllMatchingCases() + { + // Arrange + var case1Activity = new WriteLine("Case 1"); + var case2Activity = new WriteLine("Case 2"); + var case3Activity = new WriteLine("Case 3"); + var case4Activity = new WriteLine("Case 4"); + var case5Activity = new WriteLine("Case 5"); + + var switchActivity = new Switch + { + Mode = new(SwitchMode.MatchAny), + Cases = + { + new("Case 1", () => false, case1Activity), + new("Case 2", () => true, case2Activity), + new("Case 3", () => true, case3Activity), + new("Case 4", () => false, case4Activity), + new("Case 5", () => true, case5Activity) + } + }; + + // Act + var result = await _fixture.RunActivityAsync(switchActivity); + + // Assert + result.AssertActivitiesCompleted(switchActivity, case2Activity, case3Activity, case5Activity); + result.AssertActivitiesNotExecuted(case1Activity, case4Activity); + } + + [Fact(DisplayName = "Switch completes only after all scheduled activities complete")] + public async Task Switch_CompletesOnlyAfterAllScheduledActivitiesComplete() + { + // Arrange + var activity1 = new WriteLine("Activity 1"); + var activity2 = new WriteLine("Activity 2"); + var activity3 = new WriteLine("Activity 3"); + + var switchActivity = new Switch + { + Mode = new(SwitchMode.MatchAny), + Cases = + { + new("Case 1", () => true, activity1), + new("Case 2", () => true, activity2), + new("Case 3", () => true, activity3) + } + }; + + // Act + var result = await _fixture.RunActivityAsync(switchActivity); + + // Assert + result.AssertActivitiesCompleted(switchActivity, activity1, activity2, activity3); + } + + [Fact(DisplayName = "Switch completes after default activity completes")] + public async Task Switch_CompletesAfterDefaultActivityCompletes() + { + // Arrange + var case1Activity = new WriteLine("Case 1"); + var defaultActivity = new WriteLine("Default Activity"); + + var switchActivity = new Switch + { + Cases = + { + new("Case 1", () => false, case1Activity) + }, + Default = defaultActivity + }; + + // Act + var result = await _fixture.RunActivityAsync(switchActivity); + + // Assert + result.AssertActivitiesCompleted(switchActivity, defaultActivity); + result.AssertActivityNotExecuted(case1Activity); + } + + [Fact(DisplayName = "Switch with nested activities completes after all complete")] + public async Task Switch_WithNestedActivities_CompletesAfterAllComplete() + { + // Arrange + var nestedSequence1 = new Sequence + { + Activities = + { + new WriteLine("Nested 1A"), + new WriteLine("Nested 1B") + } + }; + + var nestedSequence2 = new Sequence + { + Activities = + { + new WriteLine("Nested 2A"), + new WriteLine("Nested 2B") + } + }; + + var switchActivity = new Switch + { + Mode = new(SwitchMode.MatchAny), + Cases = + { + new("Case 1", () => true, nestedSequence1), + new("Case 2", () => true, nestedSequence2) + } + }; + + // Act + var result = await _fixture.RunActivityAsync(switchActivity); + + // Assert + result.AssertActivitiesCompleted(switchActivity, nestedSequence1, nestedSequence2); + } + + [Fact(DisplayName = "Switch in MatchFirst mode with single match")] + public async Task Switch_InMatchFirstMode_WithSingleMatch() + { + // Arrange + var case1Activity = new WriteLine("Case 1"); + var case2Activity = new WriteLine("Case 2"); + var case3Activity = new WriteLine("Case 3"); + + var switchActivity = new Switch + { + Mode = new(SwitchMode.MatchFirst), + Cases = + { + new("Case 1", () => false, case1Activity), + new("Case 2", () => false, case2Activity), + new("Case 3", () => true, case3Activity) + } + }; + + // Act + var result = await _fixture.RunActivityAsync(switchActivity); + + // Assert + result.AssertActivitiesCompleted(switchActivity, case3Activity); + result.AssertActivitiesNotExecuted(case1Activity, case2Activity); + } + + [Fact(DisplayName = "Switch evaluates conditions with expression context")] + public async Task Switch_EvaluatesConditionsWithExpressionContext() + { + // Arrange + var conditionValue = true; + var case1Activity = new WriteLine("Case 1"); + var case2Activity = new WriteLine("Case 2"); + var case3Activity = new WriteLine("Case 3"); + + var switchActivity = new Switch + { + Cases = + { + new("Case 1", _ => false, case1Activity), + new("Case 2", _ => conditionValue, case2Activity), + new("Case 3", _ => false, case3Activity) + } + }; + + // Act + var result = await _fixture.RunActivityAsync(switchActivity); + + // Assert + result.AssertActivitiesCompleted(switchActivity, case2Activity); + result.AssertActivitiesNotExecuted(case1Activity, case3Activity); + } +} \ No newline at end of file