diff --git a/doc/qa/test-guidelines.md b/doc/qa/test-guidelines.md index 81feb0519..e97edc817 100644 --- a/doc/qa/test-guidelines.md +++ b/doc/qa/test-guidelines.md @@ -195,7 +195,6 @@ public async Task Should_Return_Default_Outcome() Assert.True(context.HasOutcome("Default")); } ``` - #### **Integration tests:** - Place the activity inside a minimal workflow definition and run via [`IWorkflowRunner.RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs). Assert outputs/variables and that the activity integrates correctly with preceding/following activities. - If activity creates bookmarks or relies on scheduler semantics, integration tests should resume bookmarks via the engine APIs to validate resumption. diff --git a/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs b/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs index 0b077d1c9..630698f29 100644 --- a/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs +++ b/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs @@ -128,12 +128,15 @@ public class ActivityTestFixture { var activityType = activity.GetType(); var variableProperties = activityType.GetProperties() - .Where(p => p.PropertyType.IsGenericType && - p.PropertyType.GetGenericTypeDefinition() == typeof(Variable<>)) + .Where(p => typeof(Variable).IsAssignableFrom(p.PropertyType)) .ToList(); - foreach (var variable in variableProperties.Select(property => (Variable)property.GetValue(activity)!)) + foreach (var variable in variableProperties.Select(property => (Variable?)property.GetValue(activity))) { + if(variable == null) + continue; + + context.WorkflowExecutionContext.MemoryRegister.Declare(variable); variable.Set(context.ExpressionExecutionContext, variable.Value); } diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs index a3dacfb73..c41419322 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs @@ -14,9 +14,8 @@ namespace Elsa.Workflows.Activities.Flowchart.Activities; /// Note that this activity is no longer necessary for either AND or OR merges, because all activities inherit the Join Kind property. /// Use this activity if an explicit join step is desired. /// -[Activity("Elsa", "Branching", "[Obsolete] - Explicitly merge multiple branches into a single branch of execution.", DisplayName = "Join")] +[Activity("Elsa", "Branching", "Explicitly merge multiple branches into a single branch of execution.", DisplayName = "Join")] [UsedImplicitly] -[Obsolete("Each activity now supports the MergeMode property, making the use of this activity obsolete.", false)] public class FlowJoin : Activity, IJoinNode { /// @@ -38,8 +37,9 @@ public class FlowJoin : Activity, IJoinNode protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) { if(!Flowchart.UseTokenFlow) - await context.ParentActivityExecutionContext.CancelInboundAncestorsAsync(this); - + if (context.ParentActivityExecutionContext != null) + await context.ParentActivityExecutionContext.CancelInboundAncestorsAsync(this); + await context.CompleteActivityAsync(); } diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExtensions.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExtensions.cs index 3e26ce616..176126183 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExtensions.cs @@ -8,8 +8,16 @@ public static class ActivityExtensions { public static MergeMode? GetMergeMode(this IActivity activity) { - activity.CustomProperties.TryGetValue("mergeMode", out var mergeModeString); - return Enum.TryParse((string?)mergeModeString, true, out var mergeMode) ? mergeMode : null; + if (!activity.CustomProperties.TryGetValue("mergeMode", out var value)) + return null; + + // Handle both string and enum values for backwards compatibility + return value switch + { + MergeMode mode => mode, + string str when Enum.TryParse(str, true, out var mode) => mode, + _ => null + }; } public static void SetMergeMode(this IActivity activity, MergeMode? value) @@ -17,7 +25,7 @@ public static class ActivityExtensions if (value == null) activity.CustomProperties.Remove("mergeMode"); else - activity.CustomProperties["mergeMode"] = value; + activity.CustomProperties["mergeMode"] = value.ToString()!; } public static async Task GetMergeModeAsync(this IActivity activity, ActivityExecutionContext context) diff --git a/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartCounterBasedTests.cs b/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartCounterBasedTests.cs new file mode 100644 index 000000000..d38200035 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartCounterBasedTests.cs @@ -0,0 +1,353 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Activities.Flowchart.Activities; +using Elsa.Workflows.Activities.Flowchart.Models; +using Xunit.Abstractions; +using static Elsa.Activities.IntegrationTests.Flow.FlowchartTestHelpers; + +namespace Elsa.Activities.IntegrationTests.Flow; + +/// +/// Integration tests for counter-based flowchart execution strategy. +/// +[Collection("FlowchartTests")] +public class FlowchartCounterBasedTests : IDisposable +{ + private readonly IServiceProvider _services; + private readonly CapturingTextWriter _output; + private readonly bool _originalFlowMode; + + public FlowchartCounterBasedTests(ITestOutputHelper testOutputHelper) + { + _output = new(); + _services = CreateServiceProvider(testOutputHelper, _output); + _originalFlowMode = Flowchart.UseTokenFlow; + Flowchart.UseTokenFlow = false; + } + + public void Dispose() + { + Flowchart.UseTokenFlow = _originalFlowMode; + } + + [Fact(DisplayName = "Executes simple linear flowchart")] + public async Task ExecutesSimpleLinearFlowchart() + { + // Arrange + var flowchart = CreateSimpleLinearFlowchart( + new WriteLine("First"), + new WriteLine("Second"), + new WriteLine("Third") + ); + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Equal(3, _output.Lines.Count); + Assert.Equal("First", _output.Lines.ElementAt(0)); + Assert.Equal("Second", _output.Lines.ElementAt(1)); + Assert.Equal("Third", _output.Lines.ElementAt(2)); + } + + [Fact(DisplayName = "Executes both branches in parallel flowchart")] + public async Task ExecutesBothBranches() + { + // Arrange + var start = new WriteLine("Start"); + var branch1 = new WriteLine("Branch1"); + var branch2 = new WriteLine("Branch2"); + var flowchart = CreateBranchingFlowchart(start, branch1, branch2); + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Equal(3, _output.Lines.Count); + Assert.Contains("Start", _output.Lines); + Assert.Contains("Branch1", _output.Lines); + Assert.Contains("Branch2", _output.Lines); + } + + [Fact(DisplayName = "Handles flowchart with no connections")] + public async Task HandlesNoConnections() + { + // Arrange + var activity = new WriteLine("Isolated"); + var flowchart = new Flowchart + { + Start = activity, + Activities = { activity } + }; + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Single(_output.Lines); + Assert.Equal("Isolated", _output.Lines.ElementAt(0)); + } + + [Fact(DisplayName = "Completes when start activity is null")] + public async Task CompletesWhenStartIsNull() + { + // Arrange + var flowchart = new Flowchart + { + Start = null + }; + + // Act + var result = await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.NotNull(result); + Assert.Empty(_output.Lines); + } + + [Fact(DisplayName = "Follows conditional branches with If activity")] + public async Task FollowsConditionalBranches() + { + // Arrange + var ifActivity = new If + { + Condition = new(true), + Then = new WriteLine("Then branch"), + Else = new WriteLine("Else branch") + }; + var flowchart = new Flowchart + { + Start = ifActivity, + Activities = { ifActivity } + }; + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Single(_output.Lines); + Assert.Equal("Then branch", _output.Lines.ElementAt(0)); + } + + [Fact(DisplayName = "Executes join node with WaitAny mode")] + public async Task ExecutesJoinNodeWaitAny() + { + // Arrange + var start = new WriteLine("Start"); + var branch1 = new WriteLine("Branch1"); + var branch2 = new WriteLine("Branch2"); + var join = new FlowJoin { Mode = new(FlowJoinMode.WaitAny) }; + var afterJoin = new WriteLine("AfterJoin"); + + var flowchart = new Flowchart + { + Start = start, + Activities = { start, branch1, branch2, join, afterJoin }, + Connections = + { + CreateConnection(start, branch1), + CreateConnection(start, branch2), + CreateConnection(branch1, join), + CreateConnection(branch2, join), + CreateConnection(join, afterJoin) + } + }; + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Contains("Start", _output.Lines); + Assert.Contains("AfterJoin", _output.Lines); + // At least one branch should execute + Assert.True(_output.Lines.Contains("Branch1") || _output.Lines.Contains("Branch2")); + } + + [Fact(DisplayName = "Executes join node with WaitAll mode")] + public async Task ExecutesJoinNodeWaitAll() + { + // Arrange + var start = new WriteLine("Start"); + var branch1 = new WriteLine("Branch1"); + var branch2 = new WriteLine("Branch2"); + var join = new FlowJoin { Mode = new(FlowJoinMode.WaitAll) }; + var afterJoin = new WriteLine("AfterJoin"); + + var flowchart = new Flowchart + { + Start = start, + Activities = { start, branch1, branch2, join, afterJoin }, + Connections = + { + CreateConnection(start, branch1), + CreateConnection(start, branch2), + CreateConnection(branch1, join), + CreateConnection(branch2, join), + CreateConnection(join, afterJoin) + } + }; + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Contains("Start", _output.Lines); + Assert.Contains("Branch1", _output.Lines); + Assert.Contains("Branch2", _output.Lines); + Assert.Contains("AfterJoin", _output.Lines); + } + + [Fact(DisplayName = "Handles multiple sequential joins")] + public async Task HandlesMultipleSequentialJoins() + { + // Arrange + var start = new WriteLine("Start"); + var a1 = new WriteLine("A1"); + var a2 = new WriteLine("A2"); + var join1 = new FlowJoin { Mode = new(FlowJoinMode.WaitAll) }; + var b1 = new WriteLine("B1"); + var b2 = new WriteLine("B2"); + var join2 = new FlowJoin { Mode = new(FlowJoinMode.WaitAll) }; + var end = new WriteLine("End"); + + var flowchart = new Flowchart + { + Start = start, + Activities = { start, a1, a2, join1, b1, b2, join2, end }, + Connections = + { + CreateConnection(start, a1), + CreateConnection(start, a2), + CreateConnection(a1, join1), + CreateConnection(a2, join1), + CreateConnection(join1, b1), + CreateConnection(join1, b2), + CreateConnection(b1, join2), + CreateConnection(b2, join2), + CreateConnection(join2, end) + } + }; + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Contains("Start", _output.Lines); + Assert.Contains("A1", _output.Lines); + Assert.Contains("A2", _output.Lines); + Assert.Contains("B1", _output.Lines); + Assert.Contains("B2", _output.Lines); + Assert.Contains("End", _output.Lines); + } + + [Fact(DisplayName = "Handles complex diamond pattern")] + public async Task HandlesComplexDiamondPattern() + { + // Arrange + var start = new WriteLine("Start"); + var left1 = new WriteLine("Left1"); + var left2 = new WriteLine("Left2"); + var right1 = new WriteLine("Right1"); + var right2 = new WriteLine("Right2"); + var join = new FlowJoin { Mode = new(FlowJoinMode.WaitAll) }; + var end = new WriteLine("End"); + + var flowchart = new Flowchart + { + Start = start, + Activities = { start, left1, left2, right1, right2, join, end }, + Connections = + { + CreateConnection(start, left1), + CreateConnection(start, right1), + CreateConnection(left1, left2), + CreateConnection(right1, right2), + CreateConnection(left2, join), + CreateConnection(right2, join), + CreateConnection(join, end) + } + }; + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Contains("Start", _output.Lines); + Assert.Contains("Left1", _output.Lines); + Assert.Contains("Left2", _output.Lines); + Assert.Contains("Right1", _output.Lines); + Assert.Contains("Right2", _output.Lines); + Assert.Contains("End", _output.Lines); + } + + [Fact(DisplayName = "Executes activities in correct order for sequential flow")] + public async Task ExecutesInCorrectOrderForSequential() + { + // Arrange + var flowchart = CreateSimpleLinearFlowchart( + new WriteLine("1"), + new WriteLine("2"), + new WriteLine("3"), + new WriteLine("4") + ); + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Equal(4, _output.Lines.Count); + Assert.Equal("1", _output.Lines.ElementAt(0)); + Assert.Equal("2", _output.Lines.ElementAt(1)); + Assert.Equal("3", _output.Lines.ElementAt(2)); + Assert.Equal("4", _output.Lines.ElementAt(3)); + } + + [Fact(DisplayName = "Handles nested flowcharts")] + public async Task HandlesNestedFlowcharts() + { + // Arrange + var innerFlowchart = CreateSimpleLinearFlowchart( + new WriteLine("Inner1"), + new WriteLine("Inner2") + ); + + var outerFlowchart = CreateSimpleLinearFlowchart( + new WriteLine("Outer1"), + innerFlowchart, + new WriteLine("Outer2") + ); + + // Act + await RunFlowchartAsync(_services, outerFlowchart); + + // Assert + Assert.Contains("Outer1", _output.Lines); + Assert.Contains("Inner1", _output.Lines); + Assert.Contains("Inner2", _output.Lines); + Assert.Contains("Outer2", _output.Lines); + } + + [Fact(DisplayName = "Handles unconnected activities in flowchart")] + public async Task HandlesUnconnectedActivities() + { + // Arrange + var connected = new WriteLine("Connected"); + var unconnected = new WriteLine("Unconnected"); + + var flowchart = new Flowchart + { + Start = connected, + Activities = { connected, unconnected } + // No connection to unconnected activity + }; + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Single(_output.Lines); + Assert.Equal("Connected", _output.Lines.ElementAt(0)); + Assert.DoesNotContain("Unconnected", _output.Lines); + } +} diff --git a/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartTestCollection.cs b/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartTestCollection.cs new file mode 100644 index 000000000..c5c0c1431 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartTestCollection.cs @@ -0,0 +1,10 @@ +namespace Elsa.Activities.IntegrationTests.Flow; + +/// +/// Defines a test collection to ensure flowchart tests don't run in parallel. +/// This is necessary because the tests modify the process-wide static Flowchart.UseTokenFlow flag. +/// +[CollectionDefinition("FlowchartTests", DisableParallelization = true)] +public class FlowchartTestCollection +{ +} diff --git a/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartTestHelpers.cs b/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartTestHelpers.cs new file mode 100644 index 000000000..c5ee60959 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartTestHelpers.cs @@ -0,0 +1,69 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Activities; +using Elsa.Workflows.Activities.Flowchart.Models; +using Elsa.Workflows.Models; +using Xunit.Abstractions; + +namespace Elsa.Activities.IntegrationTests.Flow; + +/// +/// Shared helper methods for Flowchart integration tests. +/// +public static class FlowchartTestHelpers +{ + public static IServiceProvider CreateServiceProvider(ITestOutputHelper testOutputHelper, CapturingTextWriter? capturingTextWriter = null) + { + var builder = new TestApplicationBuilder(testOutputHelper); + if (capturingTextWriter != null) + builder.WithCapturingTextWriter(capturingTextWriter); + return builder.Build(); + } + + public static async Task RunFlowchartAsync(IServiceProvider services, Flowchart flowchart) + { + return await services.RunActivityAsync(flowchart); + } + + public static Connection CreateConnection(IActivity source, IActivity target, string? outcome = "Done") + { + return new(new(source, outcome), new Endpoint(target)); + } + + public static Flowchart CreateSimpleLinearFlowchart(params IActivity[] activities) + { + var flowchart = new Flowchart + { + Start = activities.FirstOrDefault(), + Activities = new List(activities) + }; + + for (var i = 0; i < activities.Length - 1; i++) + { + flowchart.Connections.Add(CreateConnection(activities[i], activities[i + 1])); + } + + return flowchart; + } + + public static Flowchart CreateBranchingFlowchart(IActivity start, IActivity branch1, IActivity branch2, IActivity? join = null) + { + var flowchart = new Flowchart + { + Start = start, + Activities = { start, branch1, branch2 } + }; + + flowchart.Connections.Add(CreateConnection(start, branch1)); + flowchart.Connections.Add(CreateConnection(start, branch2)); + + if (join == null) + return flowchart; + + flowchart.Activities.Add(join); + flowchart.Connections.Add(CreateConnection(branch1, join)); + flowchart.Connections.Add(CreateConnection(branch2, join)); + + return flowchart; + } +} diff --git a/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartTokenBasedTests.cs b/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartTokenBasedTests.cs new file mode 100644 index 000000000..d1fadbf85 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartTokenBasedTests.cs @@ -0,0 +1,549 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Activities.Flowchart.Activities; +using Elsa.Workflows.Activities.Flowchart.Extensions; +using Elsa.Workflows.Activities.Flowchart.Models; +using Xunit.Abstractions; +using static Elsa.Activities.IntegrationTests.Flow.FlowchartTestHelpers; + +namespace Elsa.Activities.IntegrationTests.Flow; + +/// +/// Integration tests for token-based flowchart execution strategy. +/// +[Collection("FlowchartTests")] +public class FlowchartTokenBasedTests : IDisposable +{ + private readonly IServiceProvider _services; + private readonly CapturingTextWriter _output; + private readonly bool _originalFlowMode; + + public FlowchartTokenBasedTests(ITestOutputHelper testOutputHelper) + { + _output = new(); + _services = CreateServiceProvider(testOutputHelper, _output); + _originalFlowMode = Flowchart.UseTokenFlow; + Flowchart.UseTokenFlow = true; + } + + public void Dispose() + { + Flowchart.UseTokenFlow = _originalFlowMode; + } + + [Fact(DisplayName = "Executes simple linear flowchart")] + public async Task ExecutesSimpleLinearFlowchart() + { + // Arrange + var flowchart = CreateSimpleLinearFlowchart( + new WriteLine("First"), + new WriteLine("Second"), + new WriteLine("Third") + ); + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Equal(3, _output.Lines.Count); + Assert.Equal("First", _output.Lines.ElementAt(0)); + Assert.Equal("Second", _output.Lines.ElementAt(1)); + Assert.Equal("Third", _output.Lines.ElementAt(2)); + } + + [Fact(DisplayName = "Executes both branches in parallel flowchart")] + public async Task ExecutesBothBranches() + { + // Arrange + var start = new WriteLine("Start"); + var branch1 = new WriteLine("Branch1"); + var branch2 = new WriteLine("Branch2"); + var flowchart = CreateBranchingFlowchart(start, branch1, branch2); + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Equal(3, _output.Lines.Count); + Assert.Contains("Start", _output.Lines); + Assert.Contains("Branch1", _output.Lines); + Assert.Contains("Branch2", _output.Lines); + } + + [Fact(DisplayName = "Handles flowchart with no connections")] + public async Task HandlesNoConnections() + { + // Arrange + var activity = new WriteLine("Isolated"); + var flowchart = new Flowchart + { + Start = activity, + Activities = { activity } + }; + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Single(_output.Lines); + Assert.Equal("Isolated", _output.Lines.ElementAt(0)); + } + + [Fact(DisplayName = "Completes when start activity is null")] + public async Task CompletesWhenStartIsNull() + { + // Arrange + var flowchart = new Flowchart + { + Start = null + }; + + // Act + var result = await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.NotNull(result); + Assert.Empty(_output.Lines); + } + + [Fact(DisplayName = "Follows conditional branches with If activity")] + public async Task FollowsConditionalBranches() + { + // Arrange + var ifActivity = new If + { + Condition = new(true), + Then = new WriteLine("Then branch"), + Else = new WriteLine("Else branch") + }; + var flowchart = new Flowchart + { + Start = ifActivity, + Activities = { ifActivity } + }; + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Single(_output.Lines); + Assert.Equal("Then branch", _output.Lines.ElementAt(0)); + } + + [Fact(DisplayName = "Executes Stream merge mode - schedules immediately")] + public async Task ExecutesStreamMergeMode() + { + // Arrange + var start = new WriteLine("Start"); + var branch1 = new WriteLine("Branch1"); + var branch2 = new WriteLine("Branch2"); + var afterJoin = new WriteLine("AfterJoin"); + afterJoin.SetMergeMode(MergeMode.Stream); + + var flowchart = new Flowchart + { + Start = start, + Activities = { start, branch1, branch2, afterJoin }, + Connections = + { + CreateConnection(start, branch1), + CreateConnection(start, branch2), + CreateConnection(branch1, afterJoin), + CreateConnection(branch2, afterJoin) + } + }; + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Contains("Start", _output.Lines); + Assert.Contains("AfterJoin", _output.Lines); + // In Stream mode, afterJoin executes as soon as first branch arrives + } + + [Fact(DisplayName = "Executes Race merge mode - cancels other branches")] + public async Task ExecutesRaceMergeMode() + { + // Arrange + var start = new WriteLine("Start"); + var branch1 = new WriteLine("Branch1"); + var branch2 = new WriteLine("Branch2"); + var afterRace = new WriteLine("AfterRace"); + afterRace.SetMergeMode(MergeMode.Race); + + var flowchart = new Flowchart + { + Start = start, + Activities = { start, branch1, branch2, afterRace }, + Connections = + { + CreateConnection(start, branch1), + CreateConnection(start, branch2), + CreateConnection(branch1, afterRace), + CreateConnection(branch2, afterRace) + } + }; + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Contains("Start", _output.Lines); + Assert.Contains("AfterRace", _output.Lines); + // In Race mode, afterRace executes on first arrival and blocks others + } + + [Fact(DisplayName = "Executes Converge merge mode - waits for all branches")] + public async Task ExecutesConvergeMergeMode() + { + // Arrange + var start = new WriteLine("Start"); + var branch1 = new WriteLine("Branch1"); + var branch2 = new WriteLine("Branch2"); + var converge = new WriteLine("Converge"); + converge.SetMergeMode(MergeMode.Converge); + var end = new WriteLine("End"); + + var flowchart = new Flowchart + { + Start = start, + Activities = { start, branch1, branch2, converge, end }, + Connections = + { + CreateConnection(start, branch1), + CreateConnection(start, branch2), + CreateConnection(branch1, converge), + CreateConnection(branch2, converge), + CreateConnection(converge, end) + } + }; + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Contains("Start", _output.Lines); + Assert.Contains("Branch1", _output.Lines); + Assert.Contains("Branch2", _output.Lines); + Assert.Contains("Converge", _output.Lines); + Assert.Contains("End", _output.Lines); + } + + [Fact(DisplayName = "Executes None merge mode correctly")] + public async Task ExecutesNoneMergeMode() + { + // Arrange + var start = new WriteLine("Start"); + var branch1 = new WriteLine("Branch1"); + var branch2 = new WriteLine("Branch2"); + var noneMode = new WriteLine("NoneMode"); + noneMode.SetMergeMode(MergeMode.None); + + var flowchart = new Flowchart + { + Start = start, + Activities = { start, branch1, branch2, noneMode }, + Connections = + { + CreateConnection(start, branch1), + CreateConnection(start, branch2), + CreateConnection(branch1, noneMode), + CreateConnection(branch2, noneMode) + } + }; + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Contains("Start", _output.Lines); + Assert.Contains("Branch1", _output.Lines); + Assert.Contains("Branch2", _output.Lines); + Assert.Contains("NoneMode", _output.Lines); + } + + [Fact(DisplayName = "Handles token consumption correctly")] + public async Task HandlesTokenConsumption() + { + // Arrange + var start = new WriteLine("Start"); + var middle = new WriteLine("Middle"); + var end = new WriteLine("End"); + + var flowchart = CreateSimpleLinearFlowchart(start, middle, end); + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + // Tokens should be consumed after each activity completes + Assert.Equal(3, _output.Lines.Count); + Assert.Equal("Start", _output.Lines.ElementAt(0)); + Assert.Equal("Middle", _output.Lines.ElementAt(1)); + Assert.Equal("End", _output.Lines.ElementAt(2)); + } + + [Fact(DisplayName = "Handles multiple sequential converge nodes")] + public async Task HandlesMultipleSequentialConvergeNodes() + { + // Arrange + var start = new WriteLine("Start"); + var a1 = new WriteLine("A1"); + var a2 = new WriteLine("A2"); + var converge1 = new WriteLine("Converge1"); + converge1.SetMergeMode(MergeMode.Converge); + var b1 = new WriteLine("B1"); + var b2 = new WriteLine("B2"); + var converge2 = new WriteLine("Converge2"); + converge2.SetMergeMode(MergeMode.Converge); + var end = new WriteLine("End"); + + var flowchart = new Flowchart + { + Start = start, + Activities = { start, a1, a2, converge1, b1, b2, converge2, end }, + Connections = + { + CreateConnection(start, a1), + CreateConnection(start, a2), + CreateConnection(a1, converge1), + CreateConnection(a2, converge1), + CreateConnection(converge1, b1), + CreateConnection(converge1, b2), + CreateConnection(b1, converge2), + CreateConnection(b2, converge2), + CreateConnection(converge2, end) + } + }; + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Contains("Start", _output.Lines); + Assert.Contains("A1", _output.Lines); + Assert.Contains("A2", _output.Lines); + Assert.Contains("Converge1", _output.Lines); + Assert.Contains("B1", _output.Lines); + Assert.Contains("B2", _output.Lines); + Assert.Contains("Converge2", _output.Lines); + Assert.Contains("End", _output.Lines); + } + + [Fact(DisplayName = "Handles complex diamond pattern with tokens")] + public async Task HandlesComplexDiamondPattern() + { + // Arrange + var start = new WriteLine("Start"); + var left1 = new WriteLine("Left1"); + var left2 = new WriteLine("Left2"); + var right1 = new WriteLine("Right1"); + var right2 = new WriteLine("Right2"); + var converge = new WriteLine("Converge"); + converge.SetMergeMode(MergeMode.Converge); + var end = new WriteLine("End"); + + var flowchart = new Flowchart + { + Start = start, + Activities = { start, left1, left2, right1, right2, converge, end }, + Connections = + { + CreateConnection(start, left1), + CreateConnection(start, right1), + CreateConnection(left1, left2), + CreateConnection(right1, right2), + CreateConnection(left2, converge), + CreateConnection(right2, converge), + CreateConnection(converge, end) + } + }; + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Contains("Start", _output.Lines); + Assert.Contains("Left1", _output.Lines); + Assert.Contains("Left2", _output.Lines); + Assert.Contains("Right1", _output.Lines); + Assert.Contains("Right2", _output.Lines); + Assert.Contains("Converge", _output.Lines); + Assert.Contains("End", _output.Lines); + } + + [Fact(DisplayName = "Executes activities in correct order for sequential flow")] + public async Task ExecutesInCorrectOrderForSequential() + { + // Arrange + var flowchart = CreateSimpleLinearFlowchart( + new WriteLine("1"), + new WriteLine("2"), + new WriteLine("3"), + new WriteLine("4") + ); + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Equal(4, _output.Lines.Count); + Assert.Equal("1", _output.Lines.ElementAt(0)); + Assert.Equal("2", _output.Lines.ElementAt(1)); + Assert.Equal("3", _output.Lines.ElementAt(2)); + Assert.Equal("4", _output.Lines.ElementAt(3)); + } + + [Fact(DisplayName = "Handles nested flowcharts with tokens")] + public async Task HandlesNestedFlowcharts() + { + // Arrange + var innerFlowchart = CreateSimpleLinearFlowchart( + new WriteLine("Inner1"), + new WriteLine("Inner2") + ); + + var outerFlowchart = CreateSimpleLinearFlowchart( + new WriteLine("Outer1"), + innerFlowchart, + new WriteLine("Outer2") + ); + + // Act + await RunFlowchartAsync(_services, outerFlowchart); + + // Assert + Assert.Contains("Outer1", _output.Lines); + Assert.Contains("Inner1", _output.Lines); + Assert.Contains("Inner2", _output.Lines); + Assert.Contains("Outer2", _output.Lines); + } + + [Fact(DisplayName = "Handles unconnected activities in flowchart")] + public async Task HandlesUnconnectedActivities() + { + // Arrange + var connected = new WriteLine("Connected"); + var unconnected = new WriteLine("Unconnected"); + + var flowchart = new Flowchart + { + Start = connected, + Activities = { connected, unconnected } + // No connection to unconnected activity + }; + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Single(_output.Lines); + Assert.Equal("Connected", _output.Lines.ElementAt(0)); + Assert.DoesNotContain("Unconnected", _output.Lines); + } + + [Fact(DisplayName = "Handles mixed merge modes in complex flow")] + public async Task HandlesMixedMergeModes() + { + // Arrange + var start = new WriteLine("Start"); + var branch1 = new WriteLine("Branch1"); + var branch2 = new WriteLine("Branch2"); + var stream = new WriteLine("Stream"); + stream.SetMergeMode(MergeMode.Stream); + var branch3 = new WriteLine("Branch3"); + var branch4 = new WriteLine("Branch4"); + var converge = new WriteLine("Converge"); + converge.SetMergeMode(MergeMode.Converge); + var end = new WriteLine("End"); + + var flowchart = new Flowchart + { + Start = start, + Activities = { start, branch1, branch2, stream, branch3, branch4, converge, end }, + Connections = + { + CreateConnection(start, branch1), + CreateConnection(start, branch2), + CreateConnection(branch1, stream), + CreateConnection(branch2, stream), + CreateConnection(stream, branch3), + CreateConnection(stream, branch4), + CreateConnection(branch3, converge), + CreateConnection(branch4, converge), + CreateConnection(converge, end) + } + }; + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Contains("Start", _output.Lines); + Assert.Contains("Stream", _output.Lines); + Assert.Contains("Converge", _output.Lines); + Assert.Contains("End", _output.Lines); + } + + [Fact(DisplayName = "Handles converge with single inbound connection")] + public async Task HandlesConvergeWithSingleInbound() + { + // Arrange + var start = new WriteLine("Start"); + var single = new WriteLine("Single"); + single.SetMergeMode(MergeMode.Converge); + var end = new WriteLine("End"); + + var flowchart = CreateSimpleLinearFlowchart(start, single, end); + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + Assert.Contains("Start", _output.Lines); + Assert.Contains("Single", _output.Lines); + Assert.Contains("End", _output.Lines); + } + + [Fact(DisplayName = "Emits and consumes tokens correctly across multiple steps")] + public async Task EmitsAndConsumesTokensCorrectly() + { + // Arrange + var step1 = new WriteLine("Step1"); + var step2a = new WriteLine("Step2a"); + var step2b = new WriteLine("Step2b"); + var step3 = new WriteLine("Step3"); + step3.SetMergeMode(MergeMode.Converge); + var step4 = new WriteLine("Step4"); + + var flowchart = new Flowchart + { + Start = step1, + Activities = { step1, step2a, step2b, step3, step4 }, + Connections = + { + CreateConnection(step1, step2a), + CreateConnection(step1, step2b), + CreateConnection(step2a, step3), + CreateConnection(step2b, step3), + CreateConnection(step3, step4) + } + }; + + // Act + await RunFlowchartAsync(_services, flowchart); + + // Assert + // Verify all activities executed in a valid order + Assert.Contains("Step1", _output.Lines); + Assert.Contains("Step2a", _output.Lines); + Assert.Contains("Step2b", _output.Lines); + Assert.Contains("Step3", _output.Lines); + Assert.Contains("Step4", _output.Lines); + + // Step3 should only appear once (tokens consumed properly) + Assert.Single(_output.Lines, l => l == "Step3"); + } +} diff --git a/test/integration/Elsa.Activities.IntegrationTests/SetVariableTests.cs b/test/integration/Elsa.Activities.IntegrationTests/SetVariableTests.cs new file mode 100644 index 000000000..5a302676f --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/SetVariableTests.cs @@ -0,0 +1,63 @@ +using Elsa.Extensions; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Memory; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Elsa.Activities.IntegrationTests; + +public class SetVariableTests +{ + private readonly IWorkflowRunner _workflowRunner; + private readonly CapturingTextWriter _capturingTextWriter = new(); + private readonly IServiceProvider _services; + + public SetVariableTests(ITestOutputHelper testOutputHelper) + { + _services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build(); + _workflowRunner = _services.GetRequiredService(); + } + + [Fact(DisplayName = "SetVariable sets variable in nearest scope when multiple variables with same name exist")] + public async Task SetVariable_SetsVariableInNearestScope_WhenMultipleVariablesWithSameNameExist() + { + await _services.PopulateRegistriesAsync(); + await _workflowRunner.RunAsync(); + var lines = _capturingTextWriter.Lines.ToList(); + + // The sequence-level variable should be set to "Sequence Value" + Assert.Equal(new[] { "Sequence Value" }, lines); + } +} + +class VariableScopingWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + var workflowLevelVariable = new Variable("Foo", "Workflow Value"); + var sequenceLevelVariable = new Variable("Foo", "Initial Value"); + + workflow.Root = new Sequence + { + Variables = { workflowLevelVariable }, + Activities = + { + new Sequence + { + Variables = { sequenceLevelVariable }, + Activities = + { + new SetVariable + { + Variable = sequenceLevelVariable, + Value = new("Sequence Value") + }, + new WriteLine(context => context.GetVariable("Foo")) + } + } + } + }; + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Flow/FlowchartTestHelpers.cs b/test/unit/Elsa.Activities.UnitTests/Flow/FlowchartTestHelpers.cs new file mode 100644 index 000000000..c504f48b7 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Flow/FlowchartTestHelpers.cs @@ -0,0 +1,17 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Activities; + +namespace Elsa.Activities.UnitTests.Flow; + +/// +/// Shared helper methods for Flowchart unit tests. +/// +public static class FlowchartTestHelpers +{ + public static async Task ExecuteFlowchartAsync(Flowchart flowchart) + { + var fixture = new ActivityTestFixture(flowchart); + return await fixture.ExecuteAsync(); + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Flow/FlowchartTests.cs b/test/unit/Elsa.Activities.UnitTests/Flow/FlowchartTests.cs new file mode 100644 index 000000000..cbca233e9 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Flow/FlowchartTests.cs @@ -0,0 +1,93 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows.Activities.Flowchart.Activities; +using static Elsa.Activities.UnitTests.Flow.FlowchartTestHelpers; + +namespace Elsa.Activities.UnitTests.Flow; + +/// +/// Tests for common Flowchart behavior (both counter and token-based strategies). +/// +public class FlowchartTests +{ + [Fact(DisplayName = "Schedules start activity when specified")] + public async Task SchedulesStartActivity() + { + // Arrange + var startActivity = new WriteLine("Start"); + var flowchart = new Flowchart + { + Start = startActivity + }; + + // Act + var context = await ExecuteFlowchartAsync(flowchart); + + // Assert + Assert.True(context.HasScheduledActivity(startActivity)); + } + + [Fact(DisplayName = "Executes without error when no start activity specified")] + public async Task ExecutesWithoutErrorWhenNoStartActivity() + { + // Arrange + var flowchart = new Flowchart + { + Start = null + }; + + // Act + var context = await ExecuteFlowchartAsync(flowchart); + + // Assert + Assert.NotNull(context); + Assert.False(context.HasScheduledActivity(new WriteLine("NonExistent"))); + } + + [Theory(DisplayName = "Respects UseTokenFlow flag")] + [InlineData(true)] + [InlineData(false)] + public async Task RespectsUseTokenFlowFlag(bool useTokenFlow) + { + // Arrange + var originalValue = Flowchart.UseTokenFlow; + Flowchart.UseTokenFlow = useTokenFlow; + + try + { + var activity = new WriteLine("Test"); + var flowchart = new Flowchart + { + Start = activity, + Activities = { activity } + }; + + // Act + var context = await ExecuteFlowchartAsync(flowchart); + + // Assert - just verify it executes without error + Assert.NotNull(context); + } + finally + { + Flowchart.UseTokenFlow = originalValue; + } + } + + [Fact(DisplayName = "Accepts empty connections collection")] + public async Task AcceptsEmptyConnections() + { + // Arrange + var activity = new WriteLine("Isolated"); + var flowchart = new Flowchart + { + Start = activity, + Activities = { activity } + }; + + // Act + var context = await ExecuteFlowchartAsync(flowchart); + + // Assert + Assert.True(context.HasScheduledActivity(activity)); + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Http/SendHttpRequestTests.cs b/test/unit/Elsa.Activities.UnitTests/Http/SendHttpRequestTests.cs index 62c9e4f21..461b29c83 100644 --- a/test/unit/Elsa.Activities.UnitTests/Http/SendHttpRequestTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/Http/SendHttpRequestTests.cs @@ -285,4 +285,4 @@ public class SendHttpRequestTests { return (_, _) => throw ((TException)Activator.CreateInstance(typeof(TException), message)!); } -} +} \ No newline at end of file diff --git a/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableOfTTests.cs b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableOfTTests.cs new file mode 100644 index 000000000..81e440bb3 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableOfTTests.cs @@ -0,0 +1,51 @@ +using Elsa.Testing.Shared; + +namespace Elsa.Activities.UnitTests.Primitives; + +public class SetVariableOfTTests +{ + [Fact] + public async Task Should_Set_Variable_Integer() + { + // Arrange + const int expected = 42; // The answer to life, the universe and everything. + var variable = new Variable("myVar", 0, "myVar"); + var setVariable = new SetVariable(variable, new Input(expected)); + + // Act + var fixture = new ActivityTestFixture(setVariable); + var context = await fixture.ExecuteAsync(); + + // Assert + var result = variable.Get(context); + Assert.Equal(expected, result); + } + + [Fact] + public async Task Should_Throw_When_Variable_Is_Null() + { + // Arrange + var setVariable = new SetVariable(null!, new Input("test value")); + + // Act & Assert + var exception = await Record.ExceptionAsync(() => new ActivityTestFixture(setVariable).ExecuteAsync()); + + Assert.NotNull(exception); + } + + [Fact] + public async Task Should_Set_Variable_To_Null_Value() + { + // Arrange + var variable = new Variable("myVar", "initial value", "myVar"); + var setVariable = new SetVariable(variable, new Input((string?)null)); + + // Act + var fixture = new ActivityTestFixture(setVariable); + var context = await fixture.ExecuteAsync(); + + // Assert + var result = variable.Get(context); + Assert.Null(result); + } +} \ No newline at end of file diff --git a/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs index 361848f8e..b07b52aac 100644 --- a/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs @@ -1,4 +1,6 @@ -using Elsa.Testing.Shared; +using Elsa.Expressions.Models; +using Elsa.Extensions; +using Elsa.Testing.Shared; using Elsa.Workflows; namespace Elsa.Activities.UnitTests.Primitives; @@ -6,12 +8,16 @@ namespace Elsa.Activities.UnitTests.Primitives; public class SetVariableTests { [Fact] - public async Task Should_Set_Variable_Integer() + public async Task Should_Set_Variable() { // Arrange - const int expected = 42; // The answer to life, the universe and everything. - var variable = new Variable("myVar", 0, "myVar"); - var setVariable = new SetVariable(variable, new Input(expected)); + const int expected = 42; + var variable = new Variable("myVar", 0, "myVar"); + var setVariable = new SetVariable + { + Variable = variable, + Value = new(expected) + }; // Act var context = await ExecuteAsync(setVariable); @@ -25,12 +31,17 @@ public class SetVariableTests public async Task Should_Throw_When_Variable_Is_Null() { // Arrange - var setVariable = new SetVariable(null!, new Input("test value")); + var setVariable = new SetVariable + { + Variable = null, + Value = new("test value") + }; // Act & Assert var exception = await Record.ExceptionAsync(() => ExecuteAsync(setVariable)); Assert.NotNull(exception); + Assert.IsType(exception); } [Fact] @@ -38,7 +49,11 @@ public class SetVariableTests { // Arrange var variable = new Variable("myVar", "initial value", "myVar"); - var setVariable = new SetVariable(variable, new Input((string?)null)); + var setVariable = new SetVariable + { + Variable = variable, + Value = new(new Literal(null)) + }; // Act var context = await ExecuteAsync(setVariable); @@ -47,9 +62,9 @@ public class SetVariableTests var result = variable.Get(context); Assert.Null(result); } - - private static Task ExecuteAsync(IActivity activity) + + private static async Task ExecuteAsync(IActivity activity) { - return new ActivityTestFixture(activity).ExecuteAsync(); + return await new ActivityTestFixture(activity).ExecuteAsync(); } } \ No newline at end of file