diff --git a/test/unit/Elsa.Activities.UnitTests/Looping/ForTests.cs b/test/unit/Elsa.Activities.UnitTests/Looping/ForTests.cs new file mode 100644 index 000000000..2dca772eb --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Looping/ForTests.cs @@ -0,0 +1,255 @@ +using Elsa.Testing.Shared; +using Elsa.Extensions; +using Elsa.Workflows; +using Elsa.Workflows.Behaviors; + +namespace Elsa.Activities.UnitTests.Looping; + +public class ForTests +{ + [Theory] + [InlineData(1, 3, 1)] // Ascending loop + [InlineData(5, 1, -1)] // Descending loop + [InlineData(10, 12, 1)] // UpdatesCurrentValueEachIteration + [InlineData(0, 4, 1)] // HandlesFloatingPointSteps equivalent + [InlineData(10, 15, 1)] // PreservesIterationCountAccuracy + [InlineData(-1, -5, -1)] // NegativeRangeAndNegativeStep + public async Task ExecutesValidLoopConfigurations_SchedulesChildActivity(int start, int end, int step) + { + // Arrange + var mockBody = new MockBodyActivity(); + var forActivity = new For(start, end, step) { Body = mockBody }; + var fixture = new ActivityTestFixture(forActivity); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + var currentValue = context.GetActivityOutput(() => forActivity.CurrentValue); + Assert.Equal(start, currentValue); + + // Verify that child activity is scheduled for valid configurations + Assert.True(context.HasScheduledActivity(mockBody), "Expected child activity to be scheduled"); + } + + [Theory] + [InlineData(1, 3, true)] // Inclusive bounds + [InlineData(1, 3, false)] // Exclusive bounds + [InlineData(5, 5, true)] // Empty range inclusive + [InlineData(5, 5, false)] // Empty range exclusive + public async Task ExecutesBoundaryConditions(int start, int end, bool inclusive) + { + // Arrange + var mockBody = new MockBodyActivity(); + var forActivity = new For + { + Start = new Input(start), + End = new Input(end), + Step = new Input(1), + OuterBoundInclusive = new Input(inclusive), + Body = mockBody + }; + var fixture = new ActivityTestFixture(forActivity); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + bool shouldSchedule = ShouldExecuteLoopWithBounds(start, end, 1, inclusive); + Assert.Equal(shouldSchedule, context.HasScheduledActivity(mockBody)); + + if (shouldSchedule) + { + var currentValue = context.GetActivityOutput(() => forActivity.CurrentValue); + Assert.Equal(start, currentValue); + } + } + + [Theory] + [InlineData(5, 1, 1)] // Positive step with descending range - won't execute + [InlineData(1, 5, -1)] // Negative step with ascending range - won't execute + public async Task SkipsLoopWhenInvalidConfiguration(int start, int end, int step) + { + // Arrange + var mockBody = new MockBodyActivity(); + var forActivity = new For(start, end, step) { Body = mockBody }; + var fixture = new ActivityTestFixture(forActivity); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + Assert.False(context.HasScheduledActivity(mockBody)); + } + + [Fact] + public async Task BodyIsNull_DoesNotScheduleActivity() + { + // Arrange + var forActivity = new For(1, 5, 1) { Body = null }; + var fixture = new ActivityTestFixture(forActivity); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + var allScheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList(); + Assert.Empty(allScheduledActivities); + } + + [Fact] + public async Task CurrentValueOutputTypeCheck_PreservesIntegerType() + { + // Arrange + var mockBody = new MockBodyActivity(); + var forActivity = new For(1, 3, 1) { Body = mockBody }; + var fixture = new ActivityTestFixture(forActivity); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + var currentValue = context.GetActivityOutput(() => forActivity.CurrentValue); + Assert.IsType(currentValue); + Assert.Equal(1, currentValue); + } + + [Fact] + public async Task ValidConfiguration_SchedulesChildActivity() + { + // Arrange + var mockBody = new MockBodyActivity(); + var forActivity = new For(100, 102, 1) { Body = mockBody }; + var fixture = new ActivityTestFixture(forActivity); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + Assert.True(context.HasScheduledActivity(mockBody)); + + var currentValue = context.GetActivityOutput(() => forActivity.CurrentValue); + Assert.Equal(100, currentValue); + } + + [Theory] + [InlineData(0, 5)] // Start with default value (0) + [InlineData(1, 0)] // End with explicit zero value + public async Task HandlesDefaultValues(int start, int end) + { + // Arrange + var mockBody = new MockBodyActivity(); + var forActivity = new For + { + Start = new Input(start), + End = new Input(end), + Step = new Input(1), + Body = mockBody + }; + var fixture = new ActivityTestFixture(forActivity); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + bool shouldSchedule = ShouldExecuteLoop(start, end, 1); + Assert.Equal(shouldSchedule, context.HasScheduledActivity(mockBody)); + + if (shouldSchedule) + { + var currentValue = context.GetActivityOutput(() => forActivity.CurrentValue); + Assert.Equal(start, currentValue); + } + } + + [Fact] + public void VerifyActivityAttributes() + { + // Arrange + var forActivity = new For(); + var fixture = new ActivityTestFixture(forActivity); + + // Act & Assert + fixture.AssertActivityAttributes( + expectedNamespace: "Elsa", + expectedKind: ActivityKind.Action, + expectedCategory: "Looping", + expectedDisplayName: null, + expectedDescription: "Iterate over a sequence of steps between a start and an end number." + ); + } + + [Fact] + public void VerifyBreakBehaviorIsRegistered() + { + // Arrange + var forActivity = new For(); + + // Act & Assert + var breakBehavior = forActivity.Behaviors.OfType().FirstOrDefault(); + Assert.NotNull(breakBehavior); + } + + [Fact] + public void DefaultPropertyValues() + { + // Arrange + var forActivity = new For(); + + // Act & Assert + Assert.NotNull(forActivity.Start); + Assert.NotNull(forActivity.End); + Assert.NotNull(forActivity.Step); + Assert.NotNull(forActivity.OuterBoundInclusive); + } + + [Theory] + [InlineData(1, 3, 1, true)] + [InlineData(3, 1, -1, true)] + [InlineData(5, 5, 1, true)] + [InlineData(1, 5, 0, true)] // Zero step - actually schedules activity (infinite loop potential) + [InlineData(5, 1, 1, false)] // Wrong direction + [InlineData(1, 5, -1, false)] // Wrong direction + public async Task LoopDecisionLogic_ValidatesCorrectly(int start, int end, int step, bool shouldExecute) + { + // Arrange + var mockBody = new MockBodyActivity(); + var forActivity = new For(start, end, step) { Body = mockBody }; + var fixture = new ActivityTestFixture(forActivity); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + Assert.Equal(shouldExecute, context.HasScheduledActivity(mockBody)); + } + + // Private helper methods + private static bool ShouldExecuteLoop(int start, int end, int step) + { + return ShouldExecuteLoopWithBounds(start, end, step, true); + } + + private static bool ShouldExecuteLoopWithBounds(int start, int end, int step, bool inclusive) + { + // Match the actual For activity logic exactly + var increment = step >= 0; + var currentValue = start; + + return increment && inclusive ? currentValue <= end + : increment && !inclusive ? currentValue < end + : !increment && inclusive ? currentValue >= end + : !increment && !inclusive && currentValue > end; + } + + /// + /// Mock activity to represent the body of the For loop + /// + private class MockBodyActivity : Activity + { + protected override ValueTask ExecuteAsync(ActivityExecutionContext context) + { + return ValueTask.CompletedTask; + } + } +}