diff --git a/test/integration/Elsa.Activities.IntegrationTests/Branching/FlowDecisionTests.cs b/test/integration/Elsa.Activities.IntegrationTests/Branching/FlowDecisionTests.cs new file mode 100644 index 000000000..1853ca250 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Branching/FlowDecisionTests.cs @@ -0,0 +1,206 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Activities.Flowchart.Activities; +using Xunit.Abstractions; + +namespace Elsa.Activities.IntegrationTests.Branching; + +/// +/// Integration tests for FlowDecision activity in flowchart scenarios. +/// +[Collection("FlowchartTests")] +public class FlowDecisionTests(ITestOutputHelper testOutputHelper) : IDisposable +{ + private readonly WorkflowTestFixture _fixture = new(testOutputHelper); + private readonly bool _originalFlowMode = Flowchart.UseTokenFlow; + + public void Dispose() + { + Flowchart.UseTokenFlow = _originalFlowMode; + } + + [Theory(DisplayName = "FlowDecision follows correct path based on condition")] + [MemberData(nameof(BasicPathTestCases))] + public async Task Should_Follow_Correct_Path_Based_On_Condition(bool useTokenFlow, bool condition, string[] expectedOutputs, string[] unexpectedOutputs) + { + // Arrange + Flowchart.UseTokenFlow = useTokenFlow; + + var start = new WriteLine("Start"); + var decision = new FlowDecision(ctx => condition); + var truePath = new WriteLine("TruePath"); + var falsePath = new WriteLine("FalsePath"); + + var flowchart = new Flowchart + { + Start = start, + Activities = { start, decision, truePath, falsePath }, + Connections = + { + new() { Source = new(start, "Done"), Target = new(decision) }, + new() { Source = new(decision, "True"), Target = new(truePath) }, + new() { Source = new(decision, "False"), Target = new(falsePath) } + } + }; + + // Act + await _fixture.RunActivityAsync(flowchart); + + // Assert + AssertOutputs(expectedOutputs, unexpectedOutputs); + } + + public static IEnumerable BasicPathTestCases() + { + // useTokenFlow, condition, expectedOutputs, unexpectedOutputs + yield return [true, true, new[] { "Start", "TruePath" }, new[] { "FalsePath" }]; + yield return [true, false, new[] { "Start", "FalsePath" }, new[] { "TruePath" }]; + yield return [false, true, new[] { "Start", "TruePath" }, new[] { "FalsePath" }]; + yield return [false, false, new[] { "Start", "FalsePath" }, new[] { "TruePath" }]; + } + + [Theory(DisplayName = "FlowDecision handles nested decisions")] + [MemberData(nameof(NestedDecisionTestCases))] + public async Task Should_Handle_Nested_Decisions(bool useTokenFlow, bool outerCondition, bool innerCondition, string[] expectedOutputs, string[] unexpectedOutputs) + { + // Arrange + Flowchart.UseTokenFlow = useTokenFlow; + + var start = new WriteLine("Start"); + var outerDecision = new FlowDecision(ctx => outerCondition); + var innerDecision = new FlowDecision(ctx => innerCondition); + var innerTrue = new WriteLine("InnerTrue"); + var innerFalse = new WriteLine("InnerFalse"); + var outerFalse = new WriteLine("OuterFalse"); + + var flowchart = new Flowchart + { + Start = start, + Activities = { start, outerDecision, innerDecision, innerTrue, innerFalse, outerFalse }, + Connections = + { + new() { Source = new(start, "Done"), Target = new(outerDecision) }, + new() { Source = new(outerDecision, "True"), Target = new(innerDecision) }, + new() { Source = new(outerDecision, "False"), Target = new(outerFalse) }, + new() { Source = new(innerDecision, "True"), Target = new(innerTrue) }, + new() { Source = new(innerDecision, "False"), Target = new(innerFalse) } + } + }; + + // Act + await _fixture.RunActivityAsync(flowchart); + + // Assert + AssertOutputs(expectedOutputs, unexpectedOutputs); + } + + public static IEnumerable NestedDecisionTestCases() + { + // useTokenFlow, outerCondition, innerCondition, expectedOutputs, unexpectedOutputs + yield return [true, true, true, new[] { "Start", "InnerTrue" }, new[] { "InnerFalse", "OuterFalse" }]; + yield return [true, true, false, new[] { "Start", "InnerFalse" }, new[] { "InnerTrue", "OuterFalse" }]; + yield return [true, false, true, new[] { "Start", "OuterFalse" }, new[] { "InnerTrue", "InnerFalse" }]; + yield return [true, false, false, new[] { "Start", "OuterFalse" }, new[] { "InnerTrue", "InnerFalse" }]; + yield return [false, true, true, new[] { "Start", "InnerTrue" }, new[] { "InnerFalse", "OuterFalse" }]; + yield return [false, true, false, new[] { "Start", "InnerFalse" }, new[] { "InnerTrue", "OuterFalse" }]; + yield return [false, false, true, new[] { "Start", "OuterFalse" }, new[] { "InnerTrue", "InnerFalse" }]; + yield return [false, false, false, new[] { "Start", "OuterFalse" }, new[] { "InnerTrue", "InnerFalse" }]; + } + + [Theory(DisplayName = "FlowDecision works with only one path connected")] + [MemberData(nameof(OnePathConnectedTestCases))] + public async Task Should_Work_With_Only_One_Path_Connected(bool useTokenFlow, bool condition, string[] expectedOutputs, string[] unexpectedOutputs) + { + // Arrange + Flowchart.UseTokenFlow = useTokenFlow; + + var start = new WriteLine("Start"); + var decision = new FlowDecision(ctx => condition); + var truePath = new WriteLine("TruePath"); + var end = new WriteLine("End"); + + var flowchart = new Flowchart + { + Start = start, + Activities = { start, decision, truePath, end }, + Connections = + { + new() { Source = new(start, "Done"), Target = new(decision) }, + new() { Source = new(decision, "True"), Target = new(truePath) }, + // False path not connected + new() { Source = new(truePath, "Done"), Target = new(end) } + } + }; + + // Act + await _fixture.RunActivityAsync(flowchart); + + // Assert + AssertOutputs(expectedOutputs, unexpectedOutputs); + } + + public static IEnumerable OnePathConnectedTestCases() + { + // useTokenFlow, condition, expectedOutputs, unexpectedOutputs + yield return [true, true, new[] { "Start", "TruePath", "End" }, Array.Empty()]; + yield return [true, false, new[] { "Start" }, new[] { "TruePath", "End" }]; + yield return [false, true, new[] { "Start", "TruePath", "End" }, Array.Empty()]; + yield return [false, false, new[] { "Start" }, new[] { "TruePath", "End" }]; + } + + [Theory(DisplayName = "FlowDecision converges paths correctly")] + [MemberData(nameof(ConvergePathsTestCases))] + public async Task Should_Converge_Paths_Correctly(bool useTokenFlow, bool condition, string[] expectedOutputs, string[] unexpectedOutputs) + { + // Arrange + Flowchart.UseTokenFlow = useTokenFlow; + + var start = new WriteLine("Start"); + var decision = new FlowDecision(ctx => condition); + var truePath = new WriteLine("TruePath"); + var falsePath = new WriteLine("FalsePath"); + var converge = new WriteLine("Converge"); + + var flowchart = new Flowchart + { + Start = start, + Activities = { start, decision, truePath, falsePath, converge }, + Connections = + { + new() { Source = new(start, "Done"), Target = new(decision) }, + new() { Source = new(decision, "True"), Target = new(truePath) }, + new() { Source = new(decision, "False"), Target = new(falsePath) }, + new() { Source = new(truePath, "Done"), Target = new(converge) }, + new() { Source = new(falsePath, "Done"), Target = new(converge) } + } + }; + + // Act + await _fixture.RunActivityAsync(flowchart); + + // Assert + AssertOutputs(expectedOutputs, unexpectedOutputs); + } + + public static IEnumerable ConvergePathsTestCases() + { + // useTokenFlow, condition, expectedOutputs, unexpectedOutputs + yield return [true, true, new[] { "Start", "TruePath", "Converge" }, new[] { "FalsePath" }]; + yield return [true, false, new[] { "Start", "FalsePath", "Converge" }, new[] { "TruePath" }]; + yield return [false, true, new[] { "Start", "TruePath", "Converge" }, new[] { "FalsePath" }]; + yield return [false, false, new[] { "Start", "FalsePath", "Converge" }, new[] { "TruePath" }]; + } + + private void AssertOutputs(string[] expectedOutputs, string[] unexpectedOutputs) + { + foreach (var expected in expectedOutputs) + { + Assert.Contains(expected, _fixture.CapturingTextWriter.Lines); + } + + foreach (var unexpected in unexpectedOutputs) + { + Assert.DoesNotContain(unexpected, _fixture.CapturingTextWriter.Lines); + } + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Branching/FlowDecisionTests.cs b/test/unit/Elsa.Activities.UnitTests/Branching/FlowDecisionTests.cs new file mode 100644 index 000000000..6c8ad5c30 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Branching/FlowDecisionTests.cs @@ -0,0 +1,74 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Activities; + +namespace Elsa.Activities.UnitTests.Branching; + +public class FlowDecisionTests +{ + [Theory(DisplayName = "FlowDecision produces correct outcome and completes successfully")] + [InlineData(true, "True")] + [InlineData(false, "False")] + public async Task Should_Produce_Correct_Outcome_And_Complete(bool condition, string expectedOutcome) + { + // Arrange + var flowDecision = new FlowDecision(ctx => condition); + + // Act + var context = await ExecuteAsync(flowDecision); + + // Assert + Assert.Equal(ActivityStatus.Completed, context.Status); + Assert.True(context.HasOutcome(expectedOutcome)); + } + + [Fact(DisplayName = "FlowDecision defaults to False when no condition is set")] + public async Task Should_Default_To_False_When_No_Condition_Is_Set() + { + // Arrange + var flowDecision = new FlowDecision(); + + // Act + var context = await ExecuteAsync(flowDecision); + + // Assert + Assert.True(context.HasOutcome("False")); + } + + [Fact(DisplayName = "FlowDecision evaluates condition exactly once")] + public async Task Should_Evaluate_Condition_Exactly_Once() + { + // Arrange + var count = 0; + var flowDecision = new FlowDecision(ctx => { count++; return true; }); + + // Act + await ExecuteAsync(flowDecision); + + // Assert + Assert.Equal(1, count); + } + + [Fact(DisplayName = "FlowDecision uses latest captured state when evaluating condition")] + public async Task Should_Use_Latest_Captured_State_When_Evaluating_Condition() + { + // Arrange + var flag = false; + // ReSharper disable once AccessToModifiedClosure + var flowDecision = new FlowDecision(ctx => flag); + + // Mutate after construction, before execution + flag = true; + + // Act + var context = await ExecuteAsync(flowDecision); + + // Assert + Assert.True(context.HasOutcome("True")); + } + + private static Task ExecuteAsync(IActivity activity) + { + return new ActivityTestFixture(activity).ExecuteAsync(); + } +}