diff --git a/test/unit/Elsa.Activities.UnitTests/Http/WriteHttpResponseTests.cs b/test/unit/Elsa.Activities.UnitTests/Http/WriteHttpResponseTests.cs new file mode 100644 index 000000000..912094126 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Http/WriteHttpResponseTests.cs @@ -0,0 +1,300 @@ +using System.Net; +using System.Text; +using Elsa.Http; +using Elsa.Http.ContentWriters; +using Elsa.Http.Options; +using Elsa.Http.Parsers; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace Elsa.Activities.UnitTests.Http; + +public class WriteHttpResponseTests +{ + [Theory] + [InlineData(HttpStatusCode.OK, 200)] + [InlineData(HttpStatusCode.Created, 201)] + [InlineData(HttpStatusCode.NotFound, 404)] + [InlineData(HttpStatusCode.InternalServerError, 500)] + public async Task Should_Set_Correct_Status_Code(HttpStatusCode statusCode, int expectedStatusCode) + { + // Arrange + var (activity, httpContext) = CreateWriteHttpResponseActivity(); + activity.StatusCode = new Input(statusCode); + + // Act + var context = await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.Equal(expectedStatusCode, httpContext.Response.StatusCode); + Assert.True(context.IsCompleted); + } + + [Theory] + [InlineData("Hello World", "text/plain", "Hello World")] + [InlineData("{\"name\": \"John\"}", "application/json", "{\"name\": \"John\"}")] + [InlineData("John", "application/xml", "John")] + public async Task Should_Write_String_Content_With_Correct_Content_Type(string content, string contentType, string expectedContent) + { + // Arrange + var (activity, httpContext) = CreateWriteHttpResponseActivity(); + activity.Content = new Input(content); + activity.ContentType = new Input(contentType); + + // Act + await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.Equal(contentType, httpContext.Response.ContentType); + var responseContent = GetResponseContent(httpContext); + Assert.Equal(expectedContent, responseContent); + } + + [Fact] + public async Task Should_Serialize_Object_To_Json_When_No_Content_Type_Specified() + { + // Arrange + var (activity, httpContext) = CreateWriteHttpResponseActivity(); + var testObject = new { Name = "John", Age = 30 }; + activity.Content = new Input(testObject); + + // Act + await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.Equal("application/json", httpContext.Response.ContentType); + var responseContent = GetResponseContent(httpContext); + Assert.Contains("John", responseContent); + Assert.Contains("30", responseContent); + } + + [Theory] + [InlineData("Custom-Header", "CustomValue")] + [InlineData("X-Rate-Limit", "100")] + [InlineData("Cache-Control", "no-cache")] + public async Task Should_Add_Response_Headers(string headerName, string headerValue) + { + // Arrange + var (activity, httpContext) = CreateWriteHttpResponseActivity(); + var headers = new HttpHeaders { { headerName, new[] { headerValue } } }; + activity.ResponseHeaders = new Input(headers); + + // Act + await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.True(httpContext.Response.Headers.ContainsKey(headerName)); + Assert.Equal(headerValue, httpContext.Response.Headers[headerName]); + } + + [Fact] + public async Task Should_Add_Multiple_Response_Headers() + { + // Arrange + var (activity, httpContext) = CreateWriteHttpResponseActivity(); + var headers = new HttpHeaders + { + { "Custom-Header-1", new[] { "Value1" } }, + { "Custom-Header-2", new[] { "Value2" } }, + { "X-Rate-Limit", new[] { "100" } } + }; + activity.ResponseHeaders = new Input(headers); + + // Act + await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.True(httpContext.Response.Headers.ContainsKey("Custom-Header-1")); + Assert.True(httpContext.Response.Headers.ContainsKey("Custom-Header-2")); + Assert.True(httpContext.Response.Headers.ContainsKey("X-Rate-Limit")); + Assert.Equal("Value1", httpContext.Response.Headers["Custom-Header-1"]); + Assert.Equal("Value2", httpContext.Response.Headers["Custom-Header-2"]); + Assert.Equal("100", httpContext.Response.Headers["X-Rate-Limit"]); + } + + [Fact] + public async Task Should_Not_Write_Content_When_Status_Code_Is_NoContent() + { + // Arrange + var (activity, httpContext) = CreateWriteHttpResponseActivity(); + activity.StatusCode = new Input(HttpStatusCode.NoContent); + activity.Content = new Input("This should not be written"); + + // Act + await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.Equal(204, httpContext.Response.StatusCode); + var responseContent = GetResponseContent(httpContext); + Assert.Empty(responseContent); + } + + [Fact] + public async Task Should_Handle_Null_Content() + { + // Arrange + var (activity, httpContext) = CreateWriteHttpResponseActivity(); + activity.Content = new Input((object?)null); + + // Act + await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.Equal(200, httpContext.Response.StatusCode); + var responseContent = GetResponseContent(httpContext); + Assert.Empty(responseContent); + } + + [Fact] + public async Task Should_Call_CompleteAsync_When_WriteHttpResponseSynchronously_Is_True() + { + // Arrange + var activity = new WriteHttpResponse(); + var mockHttpContext = Substitute.For(); + var mockResponse = Substitute.For(); + var mockHeaders = new HeaderDictionary(); + var responseBody = new MemoryStream(); + + mockResponse.StatusCode.Returns(200); + mockResponse.Headers.Returns(mockHeaders); + mockResponse.Body.Returns(responseBody); + mockHttpContext.Response.Returns(mockResponse); + + // Act + await ExecuteActivityAsync(activity, mockHttpContext, writeResponseSynchronously: true); + + // Assert + await mockResponse.Received(1).CompleteAsync(); + } + + [Fact] + public async Task Should_Not_Call_CompleteAsync_When_WriteHttpResponseSynchronously_Is_False() + { + // Arrange + var activity = new WriteHttpResponse(); + var mockHttpContext = Substitute.For(); + var mockResponse = Substitute.For(); + var mockHeaders = new HeaderDictionary(); + var responseBody = new MemoryStream(); + + mockResponse.StatusCode.Returns(200); + mockResponse.Headers.Returns(mockHeaders); + mockResponse.Body.Returns(responseBody); + mockHttpContext.Response.Returns(mockResponse); + + // Act + await ExecuteActivityAsync(activity, mockHttpContext, writeResponseSynchronously: false); + + // Assert + await mockResponse.DidNotReceive().CompleteAsync(); + } + + [Fact] + public async Task Should_Create_Bookmark_When_No_HttpContext_Available() + { + // Arrange + var activity = new WriteHttpResponse(); + var fixture = new ActivityTestFixture(activity); + fixture.ConfigureServices(services => + { + var mockHttpContextAccessor = Substitute.For(); + mockHttpContextAccessor.HttpContext.Returns((HttpContext?)null); + services.AddSingleton(mockHttpContextAccessor); + services.AddSingleton(CreateMockHttpActivityOptions()); + AddHttpContentFactories(services); + services.AddSingleton(Substitute.For()); + }); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + Assert.False(context.IsCompleted); + var bookmarks = context.WorkflowExecutionContext.Bookmarks.ToList(); + Assert.Single(bookmarks); + } + + [Fact] + public async Task Should_Use_Default_Status_Code_When_Not_Specified() + { + // Arrange + var (activity, httpContext) = CreateWriteHttpResponseActivity(); + // StatusCode is not set, should default to OK (200) + + // Act + await ExecuteActivityAsync(activity, httpContext); + + // Assert + Assert.Equal(200, httpContext.Response.StatusCode); + } + + private static (WriteHttpResponse activity, HttpContext httpContext) CreateWriteHttpResponseActivity() + { + var activity = new WriteHttpResponse(); + var httpContext = new DefaultHttpContext + { + Response = + { + Body = new MemoryStream() + } + }; + + return (activity, httpContext); + } + + private static async Task ExecuteActivityAsync(WriteHttpResponse activity, HttpContext httpContext, bool writeResponseSynchronously = false) + { + var fixture = new ActivityTestFixture(activity); + fixture.ConfigureServices(services => + { + var mockHttpContextAccessor = Substitute.For(); + mockHttpContextAccessor.HttpContext.Returns(httpContext); + services.AddSingleton(mockHttpContextAccessor); + services.AddSingleton(CreateMockHttpActivityOptions(writeResponseSynchronously)); + AddHttpContentFactories(services); + AddHttpContentParsers(services); + services.AddLogging(); + }); + + return await fixture.ExecuteAsync(); + } + + private static IOptions CreateMockHttpActivityOptions(bool writeResponseSynchronously = false) + { + var options = new HttpActivityOptions + { + WriteHttpResponseSynchronously = writeResponseSynchronously + }; + var mockOptions = Substitute.For>(); + mockOptions.Value.Returns(options); + return mockOptions; + } + + private static void AddHttpContentFactories(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + private static void AddHttpContentParsers(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + private static string GetResponseContent(HttpContext httpContext) + { + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(httpContext.Response.Body); + return reader.ReadToEnd(); + } +} 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..4428660ec --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Looping/ForTests.cs @@ -0,0 +1,475 @@ +using Elsa.Testing.Shared; +using Elsa.Extensions; +using Elsa.Workflows; +using Elsa.Workflows.Behaviors; +using Elsa.Workflows.Exceptions; + +namespace Elsa.Activities.UnitTests.Looping; + +public class ForTests +{ + [Theory] + [InlineData(1, 3, 1)] // Ascending loop + [InlineData(5, 1, -1)] // Descending loop + [InlineData(-1, -5, -1)] // Descending negative loop + 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); + } + + [Theory] + [InlineData(0, 5, true)] // Start with default value (0), should execute + [InlineData(1, 0, false)] // End with explicit zero value, should not execute (wrong direction) + public async Task HandlesDefaultValues(int start, int end, bool shouldExecute) + { + // 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 + Assert.Equal(shouldExecute, context.HasScheduledActivity(mockBody)); + + if (shouldExecute) + { + 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)); + } + + // Zero step with different bounds & inclusivity (current contract: schedules at least first body) + [Theory] + [InlineData(1, 5, true, true)] // within ascending range, inclusive -> schedules + [InlineData(1, 5, false, true)] // within ascending range, exclusive -> schedules (start < end) + [InlineData(5, 1, true, false)] // start > end with ascending step (step=0 treated as positive) -> no schedule + [InlineData(5, 1, false, false)] // start > end with ascending step (step=0 treated as positive) -> no schedule + [InlineData(6, 5, true, false)] // start already outside ascending range -> no schedule + [InlineData(0, -1, false, false)]// start already outside descending (exclusive) -> no schedule + public async Task ZeroStep_RespectsInitialBoundCheck(int start, int end, bool inclusive, bool shouldSchedule) + { + // Arrange + var body = new MockBodyActivity(); + var forActivity = new For + { + Start = new Input(start), + End = new Input(end), + Step = new Input(0), // zero step: current contract allows first schedule + OuterBoundInclusive = new Input(inclusive), + Body = body + }; + var fixture = new ActivityTestFixture(forActivity); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + Assert.Equal(shouldSchedule, context.HasScheduledActivity(body)); + if (shouldSchedule) + { + var currentValue = context.GetActivityOutput(() => forActivity.CurrentValue); + Assert.IsType(currentValue); + Assert.Equal(start, currentValue); + } + } + + [Theory] + [InlineData(1, 5, 10)] // ascending, step too large + [InlineData(5, 1, -10)] // descending, step too large + public async Task StepLargerThanRange_StillSchedulesOnce(int start, int end, int step) + { + // Arrange + var body = new MockBodyActivity(); + var forActivity = new For(start, end, step) { Body = body }; + var fixture = new ActivityTestFixture(forActivity); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + Assert.True(context.HasScheduledActivity(body)); + var currentValue = context.GetActivityOutput(() => forActivity.CurrentValue); + Assert.Equal(start, currentValue); + } + + [Theory] + [InlineData(5, 5, 1)] + [InlineData(5, 5, -1)] + public async Task EqualBounds_Exclusive_DoesNotExecute(int start, int end, int step) + { + // Arrange + var body = new MockBodyActivity(); + var forActivity = new For + { + Start = new Input(start), + End = new Input(end), + Step = new Input(step), + OuterBoundInclusive = new Input(false), + Body = body + }; + var fixture = new ActivityTestFixture(forActivity); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + Assert.False(context.HasScheduledActivity(body)); + } + + [Theory] + [InlineData(3, 1, -1, true, true)] // inclusive: start (3) >= end (1) -> schedules + [InlineData(3, 3, -1, false, false)] // exclusive: start == end -> no schedule + [InlineData(2, 3, -1, true, false)] // start already below end for descending -> no schedule + public async Task Descending_InclusiveExclusive_OffByOne(int start, int end, int step, bool inclusive, bool shouldSchedule) + { + // Arrange + var body = new MockBodyActivity(); + var forActivity = new For + { + Start = new Input(start), + End = new Input(end), + Step = new Input(step), + OuterBoundInclusive = new Input(inclusive), + Body = body + }; + var fixture = new ActivityTestFixture(forActivity); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + Assert.Equal(shouldSchedule, context.HasScheduledActivity(body)); + } + + [Theory] + [InlineData(int.MaxValue, int.MaxValue, 1, true, true)] // inclusive equal -> schedules + [InlineData(int.MaxValue, int.MaxValue, 1, false, false)] // exclusive equal -> no schedule + [InlineData(int.MinValue, int.MinValue, -1, true, true)] + [InlineData(int.MinValue, int.MinValue, -1, false, false)] + public async Task ExtremeBounds_NoOverflow_OnInitialDecision(int start, int end, int step, bool inclusive, bool shouldSchedule) + { + // Arrange + var body = new MockBodyActivity(); + var forActivity = new For + { + Start = new Input(start), + End = new Input(end), + Step = new Input(step), + OuterBoundInclusive = new Input(inclusive), + Body = body + }; + var fixture = new ActivityTestFixture(forActivity); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + Assert.Equal(shouldSchedule, context.HasScheduledActivity(body)); + if (shouldSchedule) + { + var currentValue = context.GetActivityOutput(() => forActivity.CurrentValue); + Assert.Equal(start, currentValue); + } + } + + [Theory] + [InlineData("start")] + [InlineData("end")] + [InlineData("step")] + public async Task InputExpressions_Throw_DoNotSchedule(string which) + { + // Arrange + var body = new MockBodyActivity(); + var forActivity = new For + { + Start = which == "start" ? new Input((Func)(() => throw new ApplicationException("start!"))) : new Input(1), + End = which == "end" ? new Input((Func)(() => throw new ApplicationException("end!"))) : new Input(3), + Step = which == "step" ? new Input((Func)(() => throw new ApplicationException("step!"))) : new Input(1), + Body = body + }; + var fixture = new ActivityTestFixture(forActivity); + + // Act + Assert + var ex = await Assert.ThrowsAsync(() => fixture.ExecuteAsync()); + Assert.Contains(which, ex.Message, StringComparison.InvariantCultureIgnoreCase); + } + + [Fact] + public async Task BodyThrows_ActivitySchedules_WithoutBreaking() + { + // Arrange + var body = new ThrowingBody(new InvalidOperationException("boom")); + var forActivity = new For(1, 3, 1) { Body = body }; + var fixture = new ActivityTestFixture(forActivity); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + Assert.NotNull(context); + Assert.True(context.HasScheduledActivity(body)); + } + + [Theory] + [InlineData(true, 1, 5)] // positive step computed -> ascending executes + [InlineData(false, 5, 1)] // negative step computed -> descending executes + public async Task DynamicStep_EvaluatedAtExecutionTime(bool usePositive, int start, int end) + { + // Arrange + var body = new MockBodyActivity(); + var stepValue = usePositive ? 1 : -1; // Compute step value directly from parameter + var forActivity = new For + { + Start = new Input(start), + End = new Input(end), + Step = new Input((Func)(() => stepValue)), + Body = body + }; + + var fixture = new ActivityTestFixture(forActivity); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + Assert.True(context.HasScheduledActivity(body)); + var currentValue = context.GetActivityOutput(() => forActivity.CurrentValue); + Assert.Equal(start, currentValue); + } + + [Fact] + public async Task NegativeStart_NegativeStep_CurrentValueIsIntAndMatchesStart() + { + // Arrange + var body = new MockBodyActivity(); + var forActivity = new For(-2, -10, -2) { Body = body }; + var fixture = new ActivityTestFixture(forActivity); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + Assert.True(context.HasScheduledActivity(body)); + var currentValue = context.GetActivityOutput(() => forActivity.CurrentValue); + Assert.IsType(currentValue); + Assert.Equal(-2, currentValue); + } + + [Theory] + [InlineData(10, 5, 1, true)] // ascending + inclusive, start > end -> no schedule + [InlineData(10, 5, 1, false)] // ascending + exclusive, start > end -> no schedule + [InlineData(0, 5, -1, true)] // descending + inclusive, start < end -> no schedule + [InlineData(0, 5, -1, false)] // descending + exclusive, start < end -> no schedule + public async Task StartOutsideRange_DoesNotSchedule(int start, int end, int step, bool inclusive) + { + // Arrange + var body = new MockBodyActivity(); + var forActivity = new For + { + Start = new Input(start), + End = new Input(end), + Step = new Input(step), + OuterBoundInclusive = new Input(inclusive), + Body = body + }; + var fixture = new ActivityTestFixture(forActivity); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + Assert.False(context.HasScheduledActivity(body)); + } + + private static bool ShouldExecuteLoopWithBounds(int start, int end, int step, bool inclusive) + { + // Match the actual For activity logic exactly + var increment = step >= 0; + + return increment && inclusive ? start <= end + : increment && !inclusive ? start < end + : !increment && inclusive ? start >= end + : !increment && !inclusive && start > end; + } + + /// + /// Mock activity to represent the body of the For loop + /// + private class MockBodyActivity : Activity + { + protected override ValueTask ExecuteAsync(ActivityExecutionContext context) => ValueTask.CompletedTask; + } + + /// + /// A body that throws a provided exception when executed + /// + private class ThrowingBody(Exception exception) : Activity + { + protected override ValueTask ExecuteAsync(ActivityExecutionContext context) => throw exception; + } +}