From 938db5d8150260926cecff08e563305a8696a680 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 21 Apr 2025 11:40:05 +0200 Subject: [PATCH 1/2] Added the ability to have sub payloads in branches | Added tests --- CHANGELOG.md | 9 + Directory.Build.props | 2 +- README.md | 100 ++--- .../BranchWithLocalPayloadExample.cs | 193 ++++++++ Zooper.Bee.Example/Program.cs | 3 + Zooper.Bee.Tests/BranchTests.cs | 254 +++++++++++ .../BranchWithLocalPayloadTests.cs | 411 ++++++++++++++++++ Zooper.Bee.Tests/WorkflowInternalsTests.cs | 261 +++++++++++ Zooper.Bee.Tests/WorkflowTests.cs | 206 +++++++++ Zooper.Bee.Tests/Zooper.Bee.Tests.csproj | 29 ++ Zooper.Bee.sln | 6 + Zooper.Bee/BranchWithLocalPayloadBuilder.cs | 87 ++++ Zooper.Bee/IWorkflowStep.cs | 4 - Zooper.Bee/Internal/BranchActivity.cs | 46 ++ Zooper.Bee/Internal/BranchWithLocalPayload.cs | 39 ++ Zooper.Bee/Internal/EitherExtensions.cs | 23 +- Zooper.Bee/Properties/AssemblyInfo.cs | 4 + Zooper.Bee/WorkflowBuilder.cs | 127 ++++++ 18 files changed, 1732 insertions(+), 72 deletions(-) create mode 100644 Zooper.Bee.Example/BranchWithLocalPayloadExample.cs create mode 100644 Zooper.Bee.Tests/BranchTests.cs create mode 100644 Zooper.Bee.Tests/BranchWithLocalPayloadTests.cs create mode 100644 Zooper.Bee.Tests/WorkflowInternalsTests.cs create mode 100644 Zooper.Bee.Tests/WorkflowTests.cs create mode 100644 Zooper.Bee.Tests/Zooper.Bee.Tests.csproj create mode 100644 Zooper.Bee/BranchWithLocalPayloadBuilder.cs delete mode 100644 Zooper.Bee/IWorkflowStep.cs create mode 100644 Zooper.Bee/Internal/BranchActivity.cs create mode 100644 Zooper.Bee/Internal/BranchWithLocalPayload.cs create mode 100644 Zooper.Bee/Properties/AssemblyInfo.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c1f1962..6cddcaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.1.0] - 2025-04-21 + +### Added + +- Added support for branches with isolated local payloads + - New `BranchWithLocalPayload` method that allows branches to use their own payload type + - Activities within these branches can access and modify both the main payload and the local payload + - Local payloads are isolated to their branch and don't affect other parts of the workflow + ## [2.0.0] - 2025-04-19 ### Added diff --git a/Directory.Build.props b/Directory.Build.props index e1f22a7..56d06cd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,7 +13,7 @@ A .NET library for building robust, functional workflows and processing pipelines. - 2.0.0 + 2.1.0 true diff --git a/README.md b/README.md index fc96c64..b284bc8 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Zooper.Bee is a fluent, lightweight workflow framework for C# that enables you t - **Functional Style**: Uses an Either monad pattern for clear success/failure handling - **Composable**: Build complex workflows from simple, reusable components - **Comprehensive**: Support for validations, conditional activities, branches, and finally blocks +- **Isolated Branches**: Create branches with their own isolated local payload types - **Async-first**: First-class support for async/await operations - **Testable**: Workflows built with Zooper.Bee are easy to unit test - **No Dependencies**: Minimal external dependencies (only uses Zooper.Fox) @@ -97,6 +98,7 @@ var workflow = new WorkflowBuilder( .Do(...) // Add activities .DoIf(...) // Add conditional activities .Branch(...) // Add branching logic + .BranchWithLocalPayload(...) // Add branch with its own isolated payload type .Finally(...) // Add finally activities .Build(); // Build the workflow ``` @@ -158,77 +160,47 @@ Create branches for more complex conditional logic: .EndBranch() ``` -### Finally Blocks +### Branches with Local Payload -Activities that execute regardless of workflow success or failure: +Create isolated branches with their own local payload type that doesn't affect the main workflow payload: ```csharp -.Finally(LogOrderProcessing) +.BranchWithLocalPayload( + // Condition + payload => payload.NeedsCustomization, + + // Local payload factory + mainPayload => new CustomizationPayload( + AvailableOptions: new[] { "Engraving", "Gift Wrap" }, + SelectedOptions: new string[0], + CustomizationCost: 0m + ), + + // Branch configuration + branch => branch + .Do((mainPayload, localPayload) => { + // Activity can access and modify both payloads + var selectedOption = "Engraving"; + + var updatedLocalPayload = localPayload with { + SelectedOptions = new[] { selectedOption }, + CustomizationCost = 10.00m + }; + + var updatedMainPayload = mainPayload with { + FinalPrice = mainPayload.Price + updatedLocalPayload.CustomizationCost + }; + + return Either.FromRight( + (updatedMainPayload, updatedLocalPayload)); + }) +) ``` -## Advanced Usage - -### Asynchronous Operations - -Zooper.Bee has first-class support for async operations: +### Finally Blocks -```csharp -private static async Task> ProcessPaymentAsync( - OrderPayload payload, CancellationToken cancellationToken) -{ - var result = await paymentService.AuthorizePaymentAsync( - payload.Request.Amount, - cancellationToken); +Activities that execute regardless of workflow success or failure: - if (result.Success) - { - var updatedPayload = payload with { IsProcessed = true }; - return Either.FromRight(updatedPayload); - } - else - { - return Either.FromLeft( - new OrderError("PAYMENT_FAILED", result.ErrorMessage)); - } -} ``` -### Composition - -Workflows can be composed by calling one workflow from another: - -```csharp -private static async Task> RunSubWorkflow( - OrderPayload payload, CancellationToken cancellationToken) -{ - var subWorkflow = CreateSubWorkflow(); - var result = await subWorkflow.Execute(payload.SubRequest, cancellationToken); - - return result.Match( - error => Either.FromLeft(error), - success => Either.FromRight( - payload with { SubResult = success }) - ); -} ``` - -## Examples - -Check out the [Zooper.Bee.Examples](./Zooper.Bee.Examples) project for comprehensive examples including: - -- Order processing workflow -- Different execution patterns -- Complex branching logic -- Error handling scenarios - -## Contributing - -Contributions are welcome! Please feel free to submit a Pull Request. - -## License - -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. - -## Related Projects - -- [Zooper.Fox](https://github.com/zooper/fox) - Functional programming primitives used by Zooper.Bee diff --git a/Zooper.Bee.Example/BranchWithLocalPayloadExample.cs b/Zooper.Bee.Example/BranchWithLocalPayloadExample.cs new file mode 100644 index 0000000..bf09688 --- /dev/null +++ b/Zooper.Bee.Example/BranchWithLocalPayloadExample.cs @@ -0,0 +1,193 @@ +using System; +using System.Threading.Tasks; +using Zooper.Bee; +using Zooper.Fox; + +namespace Zooper.Bee.Example; + +public class BranchWithLocalPayloadExample +{ + // Request models + public record OrderRequest( + int OrderId, + string CustomerName, + decimal OrderAmount, + bool NeedsShipping); + + // Success model + public record OrderConfirmation( + int OrderId, + string CustomerName, + decimal TotalAmount, + string? ShippingTrackingNumber); + + // Error model + public record OrderError(string Code, string Message); + + // Main workflow payload model + public record OrderPayload( + int OrderId, + string CustomerName, + decimal OrderAmount, + bool NeedsShipping, + decimal TotalAmount = 0, + string? ShippingTrackingNumber = null); + + // Local payload for shipping branch + public record ShippingPayload( + string CustomerAddress, + decimal ShippingCost, + decimal PackagingCost, + decimal InsuranceCost, + string? TrackingNumber = null); + + public static async Task RunExample() + { + Console.WriteLine("\n=== Workflow Branch With Local Payload Example ===\n"); + + // Create sample requests + var standardOrder = new OrderRequest(2001, "Alice Johnson", 75.00m, false); + var shippingOrder = new OrderRequest(2002, "Bob Smith", 120.00m, true); + + // Build the order processing workflow + var workflow = CreateOrderWorkflow(); + + // Process the standard order (no shipping) + Console.WriteLine("Processing standard order (no shipping):"); + await ProcessOrder(workflow, standardOrder); + + Console.WriteLine(); + + // Process the order with shipping + Console.WriteLine("Processing order with shipping:"); + await ProcessOrder(workflow, shippingOrder); + } + + private static async Task ProcessOrder( + Workflow workflow, + OrderRequest request) + { + var result = await workflow.Execute(request); + + if (result.IsRight) + { + var confirmation = result.Right; + Console.WriteLine($"Order {confirmation.OrderId} processed successfully"); + Console.WriteLine($"Customer: {confirmation.CustomerName}"); + Console.WriteLine($"Total Amount: ${confirmation.TotalAmount}"); + + if (confirmation.ShippingTrackingNumber != null) + { + Console.WriteLine($"Shipping Tracking Number: {confirmation.ShippingTrackingNumber}"); + } + else + { + Console.WriteLine("No shipping required"); + } + } + else + { + var error = result.Left; + Console.WriteLine($"Order processing failed: [{error.Code}] {error.Message}"); + } + } + + private static Workflow CreateOrderWorkflow() + { + return new WorkflowBuilder( + // Create initial payload from request + request => new OrderPayload( + request.OrderId, + request.CustomerName, + request.OrderAmount, + request.NeedsShipping), + + // Create result from final payload + payload => new OrderConfirmation( + payload.OrderId, + payload.CustomerName, + payload.TotalAmount, + payload.ShippingTrackingNumber) + ) + // Validate order amount + .Validate(request => + { + if (request.OrderAmount <= 0) + { + return Option.Some( + new OrderError("INVALID_AMOUNT", "Order amount must be greater than zero")); + } + + return Option.None(); + }) + // Process the basic order details + .Do(payload => + { + Console.WriteLine($"Processing order {payload.OrderId} for {payload.CustomerName}..."); + + // Set the initial total amount to the order amount + return Either.FromRight( + payload with { TotalAmount = payload.OrderAmount }); + }) + // Branch with local payload for shipping-specific processing + .BranchWithLocalPayload( + // Only enter this branch if shipping is needed + payload => payload.NeedsShipping, + + // Create the local shipping payload + payload => new ShippingPayload( + CustomerAddress: "123 Example St, City, Country", // In real world, this would come from a database + ShippingCost: 12.50m, + PackagingCost: 2.75m, + InsuranceCost: 5.00m), + + // Configure the branch with shipping-specific activities + branch => branch + // First shipping activity - calculate shipping costs + .Do((mainPayload, shippingPayload) => + { + Console.WriteLine("Calculating shipping costs..."); + + // Calculate the total shipping cost + decimal totalShippingCost = + shippingPayload.ShippingCost + + shippingPayload.PackagingCost + + shippingPayload.InsuranceCost; + + // Update both payloads + var updatedMainPayload = mainPayload with + { + TotalAmount = mainPayload.OrderAmount + totalShippingCost + }; + + return Either.FromRight( + (updatedMainPayload, shippingPayload)); + }) + // Second shipping activity - generate tracking number + .Do((mainPayload, shippingPayload) => + { + Console.WriteLine("Generating shipping tracking number..."); + + // Generate a fake tracking number + string trackingNumber = $"TRACK-{Guid.NewGuid().ToString()[..8]}"; + + // Update both payloads with the tracking number + var updatedShippingPayload = shippingPayload with { TrackingNumber = trackingNumber }; + var updatedMainPayload = mainPayload with { ShippingTrackingNumber = trackingNumber }; + + return Either.FromRight( + (updatedMainPayload, updatedShippingPayload)); + }) + ) + // Finalize the order + .Do(payload => + { + Console.WriteLine($"Finalizing order {payload.OrderId}..."); + + // In a real system, we would persist the order to a database here + + return Either.FromRight(payload); + }) + .Build(); + } +} \ No newline at end of file diff --git a/Zooper.Bee.Example/Program.cs b/Zooper.Bee.Example/Program.cs index e96f09e..6703e2b 100644 --- a/Zooper.Bee.Example/Program.cs +++ b/Zooper.Bee.Example/Program.cs @@ -42,6 +42,9 @@ public static async Task Main() // Run the branching example await BranchingExample.RunExample(); + + // Run the branch with local payload example + await BranchWithLocalPayloadExample.RunExample(); } private static async Task ProcessOrder(OrderRequest request) diff --git a/Zooper.Bee.Tests/BranchTests.cs b/Zooper.Bee.Tests/BranchTests.cs new file mode 100644 index 0000000..9cf3f7c --- /dev/null +++ b/Zooper.Bee.Tests/BranchTests.cs @@ -0,0 +1,254 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; +using Zooper.Fox; + +namespace Zooper.Bee.Tests; + +public class BranchTests +{ + #region Test Models + // Request model + private record TestRequest(string Name, int Value, string Category); + + // Payload model + private record TestPayload( + string Name, + int Value, + string Category, + bool IsStandardProcessed = false, + bool IsPremiumProcessed = false, + string? ProcessingResult = null); + + // Success result model + private record TestSuccess(string Name, string ProcessingResult); + + // Error model + private record TestError(string Code, string Message); + #endregion + + [Fact] + public async Task Branch_ExecutesWhenConditionIsTrue() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new TestPayload(request.Name, request.Value, request.Category), + payload => new TestSuccess(payload.Name, payload.ProcessingResult ?? "Not processed") + ) + .Do(payload => Either.FromRight(payload)) + .Branch( + // Condition: Category is Premium + payload => payload.Category == "Premium", + branch => branch + .Do(payload => + { + var processed = payload with + { + IsPremiumProcessed = true, + ProcessingResult = "Premium Processing" + }; + return Either.FromRight(processed); + }) + ) + .Branch( + // Condition: Category is Standard + payload => payload.Category == "Standard", + branch => branch + .Do(payload => + { + var processed = payload with + { + IsStandardProcessed = true, + ProcessingResult = "Standard Processing" + }; + return Either.FromRight(processed); + }) + ) + .Build(); + + var premiumRequest = new TestRequest("Premium Test", 100, "Premium"); + var standardRequest = new TestRequest("Standard Test", 50, "Standard"); + + // Act + var premiumResult = await workflow.Execute(premiumRequest); + var standardResult = await workflow.Execute(standardRequest); + + // Assert + premiumResult.IsRight.Should().BeTrue(); + premiumResult.Right.ProcessingResult.Should().Be("Premium Processing"); + + standardResult.IsRight.Should().BeTrue(); + standardResult.Right.ProcessingResult.Should().Be("Standard Processing"); + } + + [Fact] + public async Task Branch_SkipsWhenConditionIsFalse() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new TestPayload(request.Name, request.Value, request.Category), + payload => new TestSuccess(payload.Name, payload.ProcessingResult ?? "Not processed") + ) + .Do(payload => Either.FromRight( + payload with { ProcessingResult = "Initial Processing" })) + .Branch( + // Condition: Category is Premium and Value is over 1000 + payload => payload.Category == "Premium" && payload.Value > 1000, + branch => branch + .Do(payload => Either.FromRight( + payload with + { + ProcessingResult = "VIP Processing" + })) + ) + .Build(); + + var premiumRequest = new TestRequest("Premium Test", 500, "Premium"); // Doesn't meet Value > 1000 condition + + // Act + var result = await workflow.Execute(premiumRequest); + + // Assert + result.IsRight.Should().BeTrue(); + result.Right.ProcessingResult.Should().Be("Initial Processing"); + } + + [Fact] + public async Task Branch_UnconditionalBranch_AlwaysExecutes() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new TestPayload(request.Name, request.Value, request.Category), + payload => new TestSuccess(payload.Name, payload.ProcessingResult ?? "Not processed") + ) + .Branch( + branch => branch + .Do(payload => Either.FromRight( + payload with { ProcessingResult = "Always Processed" })) + ) + .Build(); + + var anyRequest = new TestRequest("Test", 50, "Any"); + + // Act + var result = await workflow.Execute(anyRequest); + + // Assert + result.IsRight.Should().BeTrue(); + result.Right.ProcessingResult.Should().Be("Always Processed"); + } + + [Fact] + public async Task Branch_MultipleBranches_CorrectlyExecutes() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new TestPayload(request.Name, request.Value, request.Category), + payload => new TestSuccess(payload.Name, payload.ProcessingResult ?? "Not processed") + ) + .Do(payload => Either.FromRight( + payload with { ProcessingResult = "Initial" })) + .Branch( + // First branch - based on Category + payload => payload.Category == "Premium", + branch => branch + .Do(payload => Either.FromRight( + payload with { ProcessingResult = payload.ProcessingResult + " + Premium" })) + ) + .Branch( + // Second branch - based on Value + payload => payload.Value > 75, + branch => branch + .Do(payload => Either.FromRight( + payload with { ProcessingResult = payload.ProcessingResult + " + High Value" })) + ) + .Branch( + // Third branch - always executes + branch => branch + .Do(payload => Either.FromRight( + payload with { ProcessingResult = payload.ProcessingResult + " + Standard" })) + ) + .Build(); + + var premiumHighValueRequest = new TestRequest("Premium High Value", 100, "Premium"); + + // Act + var result = await workflow.Execute(premiumHighValueRequest); + + // Assert + result.IsRight.Should().BeTrue(); + // All three branches should have executed in order + result.Right.ProcessingResult.Should().Be("Initial + Premium + High Value + Standard"); + } + + [Fact] + public async Task Branch_WithError_StopsExecutionAndReturnsError() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new TestPayload(request.Name, request.Value, request.Category), + payload => new TestSuccess(payload.Name, payload.ProcessingResult ?? "Not processed") + ) + .Branch( + payload => payload.Category == "Premium", + branch => branch + .Do(payload => + { + if (payload.Value <= 0) + { + return Either.FromLeft( + new TestError("INVALID_PREMIUM_VALUE", "Premium value must be positive")); + } + return Either.FromRight( + payload with { ProcessingResult = "Premium Processing" }); + }) + ) + .Branch( + branch => branch + .Do(payload => Either.FromRight( + payload with { ProcessingResult = "Final Processing" })) + ) + .Build(); + + var invalidPremiumRequest = new TestRequest("Invalid Premium", 0, "Premium"); + + // Act + var result = await workflow.Execute(invalidPremiumRequest); + + // Assert + result.IsLeft.Should().BeTrue(); + result.Left.Code.Should().Be("INVALID_PREMIUM_VALUE"); + // The second branch should not have executed + } + + [Fact] + public async Task Branch_WithMultipleActivities_ExecutesAllInOrder() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new TestPayload(request.Name, request.Value, request.Category), + payload => new TestSuccess(payload.Name, payload.ProcessingResult ?? "Not processed") + ) + .Branch( + payload => true, + branch => branch + .Do(payload => Either.FromRight( + payload with { ProcessingResult = "Step 1" })) + .Do(payload => Either.FromRight( + payload with { ProcessingResult = payload.ProcessingResult + " -> Step 2" })) + .Do(payload => Either.FromRight( + payload with { ProcessingResult = payload.ProcessingResult + " -> Step 3" })) + ) + .Build(); + + var request = new TestRequest("Test", 50, "Standard"); + + // Act + var result = await workflow.Execute(request); + + // Assert + result.IsRight.Should().BeTrue(); + result.Right.ProcessingResult.Should().Be("Step 1 -> Step 2 -> Step 3"); + } +} \ No newline at end of file diff --git a/Zooper.Bee.Tests/BranchWithLocalPayloadTests.cs b/Zooper.Bee.Tests/BranchWithLocalPayloadTests.cs new file mode 100644 index 0000000..0cbfa7f --- /dev/null +++ b/Zooper.Bee.Tests/BranchWithLocalPayloadTests.cs @@ -0,0 +1,411 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; +using Zooper.Fox; + +namespace Zooper.Bee.Tests; + +public class BranchWithLocalPayloadTests +{ + #region Test Models + // Request model + private record ProductRequest(int Id, string Name, decimal Price, bool NeedsCustomProcessing); + + // Main workflow payload model + private record ProductPayload( + int Id, + string Name, + decimal Price, + bool NeedsCustomProcessing, + string? ProcessingResult = null, + string? CustomizationDetails = null, + decimal FinalPrice = 0); + + // Local payload for customization branch + private record CustomizationPayload( + string[] AvailableOptions, + string[] SelectedOptions, + decimal CustomizationCost, + string CustomizationDetails); + + // Success result model + private record ProductResult( + int Id, + string Name, + decimal FinalPrice, + string? ProcessingResult, + string? CustomizationDetails); + + // Error model + private record ProductError(string Code, string Message); + #endregion + + [Fact] + public async Task BranchWithLocalPayload_ExecutesWhenConditionIsTrue() + { + // Arrange + var workflow = new WorkflowBuilder( + // Create the main payload from the request + request => new ProductPayload( + request.Id, + request.Name, + request.Price, + request.NeedsCustomProcessing), + + // Create the result from the final payload + payload => new ProductResult( + payload.Id, + payload.Name, + payload.FinalPrice, + payload.ProcessingResult, + payload.CustomizationDetails) + ) + .Do(payload => + { + // Initial processing + return Either.FromRight(payload with + { + ProcessingResult = "Standard processing complete", + FinalPrice = payload.Price + }); + }) + // Branch with local payload for products that need customization + .BranchWithLocalPayload( + // Condition: Product needs custom processing + payload => payload.NeedsCustomProcessing, + + // Create the local customization payload + payload => new CustomizationPayload( + AvailableOptions: new[] { "Engraving", "Gift Wrap", "Extended Warranty" }, + SelectedOptions: new[] { "Engraving", "Gift Wrap" }, + CustomizationCost: 25.99m, + CustomizationDetails: "Custom initialized" + ), + + // Branch configuration + branch => branch + // First customization activity - process options + .Do((mainPayload, localPayload) => + { + // Process the selected options + string optionsProcessed = string.Join(", ", localPayload.SelectedOptions); + + // Update both payloads + var updatedLocalPayload = localPayload with + { + CustomizationDetails = $"Options: {optionsProcessed}" + }; + + return Either.FromRight( + (mainPayload, updatedLocalPayload)); + }) + // Second customization activity - apply costs and finalize customization + .Do((mainPayload, localPayload) => + { + // Calculate total price + decimal totalPrice = mainPayload.Price + localPayload.CustomizationCost; + + // Update both payloads + var updatedMainPayload = mainPayload with + { + FinalPrice = totalPrice, + CustomizationDetails = localPayload.CustomizationDetails, + ProcessingResult = $"{mainPayload.ProcessingResult} with customization" + }; + + return Either.FromRight( + (updatedMainPayload, localPayload)); + }) + ) + .Build(); + + var customizableProduct = new ProductRequest(1001, "Custom Widget", 99.99m, true); + var standardProduct = new ProductRequest(1002, "Standard Widget", 49.99m, false); + + // Act + var customResult = await workflow.Execute(customizableProduct); + var standardResult = await workflow.Execute(standardProduct); + + // Assert + + // Custom product should go through customization + customResult.IsRight.Should().BeTrue(); + customResult.Right.FinalPrice.Should().Be(125.98m); // 99.99 + 25.99 + customResult.Right.ProcessingResult.Should().Be("Standard processing complete with customization"); + customResult.Right.CustomizationDetails.Should().Be("Options: Engraving, Gift Wrap"); + + // Standard product should not go through customization + standardResult.IsRight.Should().BeTrue(); + standardResult.Right.FinalPrice.Should().Be(49.99m); // Just base price + standardResult.Right.ProcessingResult.Should().Be("Standard processing complete"); + standardResult.Right.CustomizationDetails.Should().BeNull(); + } + + [Fact] + public async Task BranchWithLocalPayload_LocalPayloadIsolated_NotAffectedByOtherActivities() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new ProductPayload(request.Id, request.Name, request.Price, request.NeedsCustomProcessing), + payload => new ProductResult( + payload.Id, payload.Name, payload.FinalPrice, + payload.ProcessingResult, payload.CustomizationDetails) + ) + .Do(payload => Either.FromRight(payload with + { + ProcessingResult = "Initial processing", + FinalPrice = payload.Price + })) + .BranchWithLocalPayload( + // Condition + payload => true, + + // Create local payload + _ => new CustomizationPayload( + AvailableOptions: new[] { "Option1", "Option2" }, + SelectedOptions: new[] { "Option1" }, + CustomizationCost: 10.00m, + CustomizationDetails: "Branch 1 customization" + ), + + // Branch configuration + branch => branch + .Do((mainPayload, localPayload) => + { + var updatedMainPayload = mainPayload with + { + ProcessingResult = mainPayload.ProcessingResult + " -> Branch 1", + FinalPrice = mainPayload.FinalPrice + localPayload.CustomizationCost, + CustomizationDetails = localPayload.CustomizationDetails + }; + + return Either.FromRight( + (updatedMainPayload, localPayload)); + }) + ) + // Another main activity that changes the main payload but shouldn't affect the next branch's local payload + .Do(payload => Either.FromRight(payload with + { + ProcessingResult = payload.ProcessingResult + " -> Main activity" + })) + .BranchWithLocalPayload( + // Second branch + payload => true, + + // Create a different local payload + _ => new CustomizationPayload( + AvailableOptions: new[] { "OptionA", "OptionB" }, + SelectedOptions: new[] { "OptionA", "OptionB" }, + CustomizationCost: 20.00m, + CustomizationDetails: "Branch 2 customization" + ), + + // Branch configuration + branch => branch + .Do((mainPayload, localPayload) => + { + var updatedMainPayload = mainPayload with + { + ProcessingResult = mainPayload.ProcessingResult + " -> Branch 2", + FinalPrice = mainPayload.FinalPrice + localPayload.CustomizationCost, + CustomizationDetails = mainPayload.CustomizationDetails + " + " + localPayload.CustomizationDetails + }; + + return Either.FromRight( + (updatedMainPayload, localPayload)); + }) + ) + .Build(); + + var request = new ProductRequest(1001, "Test Product", 100.00m, true); + + // Act + var result = await workflow.Execute(request); + + // Assert + result.IsRight.Should().BeTrue(); + result.Right.ProcessingResult.Should().Be("Initial processing -> Main activity -> Branch 1 -> Branch 2"); + result.Right.FinalPrice.Should().Be(130.00m); // 100 + 10 + 20 + result.Right.CustomizationDetails.Should().Be("Branch 1 customization + Branch 2 customization"); + } + + [Fact] + public async Task BranchWithLocalPayload_ErrorInBranch_StopsExecutionAndReturnsError() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new ProductPayload(request.Id, request.Name, request.Price, request.NeedsCustomProcessing), + payload => new ProductResult( + payload.Id, payload.Name, payload.FinalPrice, + payload.ProcessingResult, payload.CustomizationDetails) + ) + .Do(payload => Either.FromRight( + payload with { ProcessingResult = "Initial processing" })) + .BranchWithLocalPayload( + payload => payload.NeedsCustomProcessing, + _ => new CustomizationPayload( + AvailableOptions: new string[0], + SelectedOptions: new[] { "Unavailable Option" }, // This will cause an error + CustomizationCost: 10.00m, + CustomizationDetails: "Should fail" + ), + branch => branch + .Do((mainPayload, localPayload) => + { + // Validate selected options are available + foreach (var option in localPayload.SelectedOptions) + { + if (Array.IndexOf(localPayload.AvailableOptions, option) < 0) + { + return Either.FromLeft( + new ProductError("INVALID_OPTION", $"Option '{option}' is not available")); + } + } + + return Either.FromRight( + (mainPayload, localPayload)); + }) + ) + .Do(payload => Either.FromRight( + payload with { ProcessingResult = payload.ProcessingResult + " -> Final processing" })) + .Build(); + + var request = new ProductRequest(1001, "Test Product", 100.00m, true); + + // Act + var result = await workflow.Execute(request); + + // Assert + result.IsLeft.Should().BeTrue(); + result.Left.Code.Should().Be("INVALID_OPTION"); + result.Left.Message.Should().Be("Option 'Unavailable Option' is not available"); + } + + [Fact] + public async Task BranchWithLocalPayload_MultipleActivitiesInSameBranch_ShareLocalPayload() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new ProductPayload(request.Id, request.Name, request.Price, request.NeedsCustomProcessing), + payload => new ProductResult( + payload.Id, payload.Name, payload.FinalPrice, + payload.ProcessingResult, payload.CustomizationDetails) + ) + .BranchWithLocalPayload( + _ => true, + _ => new CustomizationPayload( + AvailableOptions: new[] { "Option1", "Option2", "Option3" }, + SelectedOptions: new string[0], // Start with no selected options + CustomizationCost: 0m, // Start with no cost + CustomizationDetails: "Start" + ), + branch => branch + // First activity - select Option1 + .Do((mainPayload, localPayload) => + { + var updatedOptions = new string[localPayload.SelectedOptions.Length + 1]; + Array.Copy(localPayload.SelectedOptions, updatedOptions, localPayload.SelectedOptions.Length); + updatedOptions[updatedOptions.Length - 1] = "Option1"; + + var updatedLocalPayload = localPayload with + { + SelectedOptions = updatedOptions, + CustomizationCost = localPayload.CustomizationCost + 10m, + CustomizationDetails = localPayload.CustomizationDetails + " -> Added Option1" + }; + + return Either.FromRight( + (mainPayload, updatedLocalPayload)); + }) + // Second activity - select Option2 + .Do((mainPayload, localPayload) => + { + var updatedOptions = new string[localPayload.SelectedOptions.Length + 1]; + Array.Copy(localPayload.SelectedOptions, updatedOptions, localPayload.SelectedOptions.Length); + updatedOptions[updatedOptions.Length - 1] = "Option2"; + + var updatedLocalPayload = localPayload with + { + SelectedOptions = updatedOptions, + CustomizationCost = localPayload.CustomizationCost + 15m, + CustomizationDetails = localPayload.CustomizationDetails + " -> Added Option2" + }; + + return Either.FromRight( + (mainPayload, updatedLocalPayload)); + }) + // Third activity - finalize and update main payload + .Do((mainPayload, localPayload) => + { + var updatedMainPayload = mainPayload with + { + FinalPrice = mainPayload.Price + localPayload.CustomizationCost, + CustomizationDetails = localPayload.CustomizationDetails, + ProcessingResult = $"Processed with {localPayload.SelectedOptions.Length} options" + }; + + return Either.FromRight( + (updatedMainPayload, localPayload)); + }) + ) + .Build(); + + var request = new ProductRequest(1001, "Test Product", 100.00m, true); + + // Act + var result = await workflow.Execute(request); + + // Assert + result.IsRight.Should().BeTrue(); + result.Right.FinalPrice.Should().Be(125.00m); // 100 + 10 + 15 + result.Right.ProcessingResult.Should().Be("Processed with 2 options"); + result.Right.CustomizationDetails.Should().Be("Start -> Added Option1 -> Added Option2"); + } + + [Fact] + public async Task BranchWithLocalPayload_UnconditionalBranch_AlwaysExecutes() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new ProductPayload(request.Id, request.Name, request.Price, request.NeedsCustomProcessing), + payload => new ProductResult( + payload.Id, payload.Name, payload.FinalPrice, + payload.ProcessingResult, payload.CustomizationDetails) + ) + .BranchWithLocalPayload( + // Local payload factory only + _ => new CustomizationPayload( + AvailableOptions: new[] { "Default Option" }, + SelectedOptions: new[] { "Default Option" }, + CustomizationCost: 5.00m, + CustomizationDetails: "Default customization" + ), + branch => branch + .Do((mainPayload, localPayload) => + { + var updatedMainPayload = mainPayload with + { + FinalPrice = mainPayload.Price + localPayload.CustomizationCost, + CustomizationDetails = localPayload.CustomizationDetails, + ProcessingResult = "Processed with default customization" + }; + + return Either.FromRight( + (updatedMainPayload, localPayload)); + }) + ) + .Build(); + + var request = new ProductRequest(1001, "Test Product", 100.00m, false); + + // Act + var result = await workflow.Execute(request); + + // Assert + result.IsRight.Should().BeTrue(); + result.Right.FinalPrice.Should().Be(105.00m); // 100 + 5 + result.Right.ProcessingResult.Should().Be("Processed with default customization"); + result.Right.CustomizationDetails.Should().Be("Default customization"); + } +} \ No newline at end of file diff --git a/Zooper.Bee.Tests/WorkflowInternalsTests.cs b/Zooper.Bee.Tests/WorkflowInternalsTests.cs new file mode 100644 index 0000000..3f74035 --- /dev/null +++ b/Zooper.Bee.Tests/WorkflowInternalsTests.cs @@ -0,0 +1,261 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; +using Zooper.Fox; + +namespace Zooper.Bee.Tests; + +/// +/// Tests for the internal execution logic of workflows using end-to-end tests. +/// +public class WorkflowInternalsTests +{ + #region Test Models + // Models for the tests + private record TestRequest(string Name, int Value); + private record TestPayload(string Name, int Value, string? Result = null); + private record TestLocalPayload(string LocalData, int ProcessingValue = 0); + private record TestSuccess(string Result); + private record TestError(string Code, string Message); + #endregion + + [Fact] + public async Task DynamicBranchExecution_ConditionTrue_ExecutesActivities() + { + // Arrange + var workflow = new WorkflowBuilder( + // Create the payload from the request + request => new TestPayload(request.Name, request.Value), + + // Create the success result from the payload + payload => new TestSuccess(payload.Result ?? "No result") + ) + .Do(payload => Either.FromRight( + payload with { Result = "Initial processing" })) + .BranchWithLocalPayload( + // Condition - always true + payload => true, + + // Create local payload + payload => new TestLocalPayload($"Local data for {payload.Name}"), + + // Branch configuration + branch => branch + .Do((mainPayload, localPayload) => + { + var updatedMainPayload = mainPayload with + { + Result = $"Processed {mainPayload.Name} with {localPayload.LocalData}" + }; + var updatedLocalPayload = localPayload with { ProcessingValue = 42 }; + + return Either.FromRight( + (updatedMainPayload, updatedLocalPayload)); + }) + ) + .Build(); + + var request = new TestRequest("TestName", 123); + + // Act + var result = await workflow.Execute(request); + + // Assert + result.IsRight.Should().BeTrue(); + result.Right.Result.Should().Be("Processed TestName with Local data for TestName"); + } + + [Fact] + public async Task DynamicBranchExecution_ConditionFalse_SkipsActivities() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new TestPayload(request.Name, request.Value), + payload => new TestSuccess(payload.Result ?? "No result") + ) + .Do(payload => Either.FromRight( + payload with { Result = "Initial processing" })) + .BranchWithLocalPayload( + // Condition - always false + payload => false, + + // Create local payload + payload => new TestLocalPayload($"Local data for {payload.Name}"), + + // Branch configuration + branch => branch + .Do((mainPayload, localPayload) => + { + var updatedMainPayload = mainPayload with + { + Result = "This should not be set" + }; + + return Either.FromRight( + (updatedMainPayload, localPayload)); + }) + ) + .Build(); + + var request = new TestRequest("TestName", 123); + + // Act + var result = await workflow.Execute(request); + + // Assert + result.IsRight.Should().BeTrue(); + result.Right.Result.Should().Be("Initial processing"); // Should remain unchanged + } + + [Fact] + public async Task DynamicBranchExecution_ActivityReturnsError_PropagatesError() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new TestPayload(request.Name, request.Value), + payload => new TestSuccess(payload.Result ?? "No result") + ) + .BranchWithLocalPayload( + // Condition - always true + payload => true, + + // Create local payload + payload => new TestLocalPayload($"Local data for {payload.Name}"), + + // Branch configuration + branch => branch + .Do((mainPayload, localPayload) => + { + return Either.FromLeft( + new TestError("TEST_ERROR", "This is a test error")); + }) + ) + .Build(); + + var request = new TestRequest("TestName", 123); + + // Act + var result = await workflow.Execute(request); + + // Assert + result.IsLeft.Should().BeTrue(); + result.Left.Code.Should().Be("TEST_ERROR"); + result.Left.Message.Should().Be("This is a test error"); + } + + [Fact] + public async Task DynamicBranchExecution_MultipleActivities_ExecutesInOrder() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new TestPayload(request.Name, request.Value), + payload => new TestSuccess(payload.Result ?? "No result") + ) + .BranchWithLocalPayload( + // Condition - always true + payload => true, + + // Create local payload + payload => new TestLocalPayload("Initial local data", 0), + + // Branch configuration + branch => branch + // First activity + .Do((mainPayload, localPayload) => + { + var updatedMainPayload = mainPayload with { Value = mainPayload.Value + 1 }; + var updatedLocalPayload = localPayload with + { + LocalData = localPayload.LocalData + " -> Step 1", + ProcessingValue = 10 + }; + + return Either.FromRight( + (updatedMainPayload, updatedLocalPayload)); + }) + // Second activity + .Do((mainPayload, localPayload) => + { + var updatedMainPayload = mainPayload with + { + Value = mainPayload.Value + localPayload.ProcessingValue, + Result = $"Result: {localPayload.LocalData}" + }; + + var updatedLocalPayload = localPayload with + { + LocalData = localPayload.LocalData + " -> Step 2", + ProcessingValue = 20 + }; + + return Either.FromRight( + (updatedMainPayload, updatedLocalPayload)); + }) + ) + .Build(); + + var request = new TestRequest("TestName", 5); + + // Act + var result = await workflow.Execute(request); + + // Assert + result.IsRight.Should().BeTrue(); + result.Right.Result.Should().Be("Result: Initial local data -> Step 1"); + } + + [Fact] + public async Task DynamicBranchExecution_MultipleBranches_ExecuteIndependently() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new TestPayload(request.Name, request.Value), + payload => new TestSuccess(payload.Result ?? "No result") + ) + .Do(payload => Either.FromRight( + payload with { Result = "Start" })) + // First branch with first local payload type + .BranchWithLocalPayload( + payload => true, + payload => new TestLocalPayload("Branch 1 data"), + branch => branch + .Do((mainPayload, localPayload) => + { + var updatedMainPayload = mainPayload with + { + Result = mainPayload.Result + " -> Branch 1" + }; + + return Either.FromRight( + (updatedMainPayload, localPayload)); + }) + ) + // Second branch with the same local payload type + .BranchWithLocalPayload( + payload => payload.Value > 0, + payload => new TestLocalPayload("Branch 2 data"), + branch => branch + .Do((mainPayload, localPayload) => + { + var updatedMainPayload = mainPayload with + { + Result = mainPayload.Result + " -> Branch 2" + }; + + return Either.FromRight( + (updatedMainPayload, localPayload)); + }) + ) + .Build(); + + var request = new TestRequest("TestName", 5); + + // Act + var result = await workflow.Execute(request); + + // Assert + result.IsRight.Should().BeTrue(); + result.Right.Result.Should().Be("Start -> Branch 1 -> Branch 2"); + } +} \ No newline at end of file diff --git a/Zooper.Bee.Tests/WorkflowTests.cs b/Zooper.Bee.Tests/WorkflowTests.cs new file mode 100644 index 0000000..40177d2 --- /dev/null +++ b/Zooper.Bee.Tests/WorkflowTests.cs @@ -0,0 +1,206 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; +using Zooper.Fox; + +namespace Zooper.Bee.Tests; + +public class WorkflowTests +{ + #region Test Models + // Request model + private record TestRequest(string Name, int Value); + + // Payload model + private record TestPayload( + string Name, + int Value, + bool IsValidated = false, + bool IsProcessed = false, + string? Result = null); + + // Success result model + private record TestSuccess(string Result); + + // Error model + private record TestError(string Code, string Message); + #endregion + + [Fact] + public async Task Execute_ValidRequest_ReturnsSuccessResult() + { + // Arrange + var workflow = new WorkflowBuilder( + // Create the payload from the request + request => new TestPayload(request.Name, request.Value), + + // Create the success result from the payload + payload => new TestSuccess(payload.Result ?? "Default") + ) + .Do(payload => + { + // Validate the payload + var validated = payload with { IsValidated = true }; + return Either.FromRight(validated); + }) + .Do(payload => + { + // Process the payload + var processed = payload with + { + IsProcessed = true, + Result = $"Processed: {payload.Name}-{payload.Value}" + }; + return Either.FromRight(processed); + }) + .Build(); + + var request = new TestRequest("Test", 42); + + // Act + var result = await workflow.Execute(request); + + // Assert + result.IsRight.Should().BeTrue(); + result.Right.Result.Should().Be("Processed: Test-42"); + } + + [Fact] + public async Task Execute_WithValidation_RejectsInvalidRequest() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new TestPayload(request.Name, request.Value), + payload => new TestSuccess(payload.Result ?? "Default") + ) + .Validate(request => + { + if (request.Value <= 0) + { + return Option.Some(new TestError("INVALID_VALUE", "Value must be positive")); + } + return Option.None(); + }) + .Do(payload => Either.FromRight(payload with { IsProcessed = true })) + .Build(); + + var invalidRequest = new TestRequest("Test", -5); + + // Act + var result = await workflow.Execute(invalidRequest); + + // Assert + result.IsLeft.Should().BeTrue(); + result.Left.Code.Should().Be("INVALID_VALUE"); + result.Left.Message.Should().Be("Value must be positive"); + } + + [Fact] + public async Task Execute_WithConditionalActivity_OnlyExecutesWhenConditionMet() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new TestPayload(request.Name, request.Value), + payload => new TestSuccess(payload.Result ?? "Default") + ) + .Do(payload => Either.FromRight( + payload with { IsValidated = true })) + .DoIf( + // Condition: Value is greater than 50 + payload => payload.Value > 50, + // Activity to execute when condition is true + payload => Either.FromRight( + payload with { Result = "High Value Processing" }) + ) + .DoIf( + // Condition: Value is less than or equal to 50 + payload => payload.Value <= 50, + // Activity to execute when condition is true + payload => Either.FromRight( + payload with { Result = "Standard Processing" }) + ) + .Build(); + + var lowValueRequest = new TestRequest("Test", 42); + var highValueRequest = new TestRequest("Test", 100); + + // Act + var lowValueResult = await workflow.Execute(lowValueRequest); + var highValueResult = await workflow.Execute(highValueRequest); + + // Assert + lowValueResult.Right.Result.Should().Be("Standard Processing"); + highValueResult.Right.Result.Should().Be("High Value Processing"); + } + + [Fact] + public async Task Execute_WithErrorInActivity_ReturnsError() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new TestPayload(request.Name, request.Value), + payload => new TestSuccess(payload.Result ?? "Default") + ) + .Do(payload => + { + if (payload.Value == 0) + { + return Either.FromLeft( + new TestError("ZERO_VALUE", "Value cannot be zero")); + } + return Either.FromRight( + payload with { IsValidated = true }); + }) + .Do(payload => Either.FromRight( + payload with { IsProcessed = true })) + .Build(); + + var zeroValueRequest = new TestRequest("Test", 0); + + // Act + var result = await workflow.Execute(zeroValueRequest); + + // Assert + result.IsLeft.Should().BeTrue(); + result.Left.Code.Should().Be("ZERO_VALUE"); + } + + [Fact] + public async Task Execute_WithFinallyActivities_ExecutesThemEvenOnError() + { + // Arrange + bool finallyExecuted = false; + + var workflow = new WorkflowBuilder( + request => new TestPayload(request.Name, request.Value), + payload => new TestSuccess(payload.Result ?? "Default") + ) + .Do(payload => + { + if (payload.Value < 0) + { + return Either.FromLeft( + new TestError("NEGATIVE_VALUE", "Value cannot be negative")); + } + return Either.FromRight( + payload with { IsValidated = true }); + }) + .Finally(payload => + { + finallyExecuted = true; + return Either.FromRight(payload); + }) + .Build(); + + var invalidRequest = new TestRequest("Test", -10); + + // Act + var result = await workflow.Execute(invalidRequest); + + // Assert + result.IsLeft.Should().BeTrue(); + finallyExecuted.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/Zooper.Bee.Tests/Zooper.Bee.Tests.csproj b/Zooper.Bee.Tests/Zooper.Bee.Tests.csproj new file mode 100644 index 0000000..dbb0d00 --- /dev/null +++ b/Zooper.Bee.Tests/Zooper.Bee.Tests.csproj @@ -0,0 +1,29 @@ + + + + net6.0 + latest + enable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/Zooper.Bee.sln b/Zooper.Bee.sln index 6816e4e..9286a67 100644 --- a/Zooper.Bee.sln +++ b/Zooper.Bee.sln @@ -4,6 +4,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zooper.Bee", "Zooper.Bee\Zo EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zooper.Bee.Example", "Zooper.Bee.Example\Zooper.Bee.Example.csproj", "{F654D462-40EE-40D1-9871-CF9DFAC82A90}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zooper.Bee.Tests", "Zooper.Bee.Tests\Zooper.Bee.Tests.csproj", "{3AC893B3-275D-44C6-ACCD-1A0B2A3D0A7C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -18,5 +20,9 @@ Global {F654D462-40EE-40D1-9871-CF9DFAC82A90}.Debug|Any CPU.Build.0 = Debug|Any CPU {F654D462-40EE-40D1-9871-CF9DFAC82A90}.Release|Any CPU.ActiveCfg = Release|Any CPU {F654D462-40EE-40D1-9871-CF9DFAC82A90}.Release|Any CPU.Build.0 = Release|Any CPU + {3AC893B3-275D-44C6-ACCD-1A0B2A3D0A7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3AC893B3-275D-44C6-ACCD-1A0B2A3D0A7C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3AC893B3-275D-44C6-ACCD-1A0B2A3D0A7C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3AC893B3-275D-44C6-ACCD-1A0B2A3D0A7C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/Zooper.Bee/BranchWithLocalPayloadBuilder.cs b/Zooper.Bee/BranchWithLocalPayloadBuilder.cs new file mode 100644 index 0000000..cb5559a --- /dev/null +++ b/Zooper.Bee/BranchWithLocalPayloadBuilder.cs @@ -0,0 +1,87 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Zooper.Bee.Internal; +using Zooper.Fox; + +namespace Zooper.Bee; + +/// +/// Builder for a branch with a local payload that enables a fluent API for adding activities. +/// +/// The type of the request input +/// The type of the main workflow payload +/// The type of the local branch payload +/// The type of the success result +/// The type of the error result +public sealed class BranchWithLocalPayloadBuilder +{ + private readonly WorkflowBuilder _workflow; + private readonly BranchWithLocalPayload _branch; + + internal BranchWithLocalPayloadBuilder( + WorkflowBuilder workflow, + BranchWithLocalPayload branch) + { + _workflow = workflow; + _branch = branch; + } + + /// + /// Adds an activity to the branch that operates on both the main and local payloads. + /// + /// The activity to add + /// The branch builder for fluent chaining + public BranchWithLocalPayloadBuilder Do( + Func>> activity) + { + _branch.Activities.Add(new BranchActivity(activity)); + return this; + } + + /// + /// Adds a synchronous activity to the branch that operates on both the main and local payloads. + /// + /// The activity to add + /// The branch builder for fluent chaining + public BranchWithLocalPayloadBuilder Do( + Func> activity) + { + _branch.Activities.Add(new BranchActivity( + (mainPayload, localPayload, _) => Task.FromResult(activity(mainPayload, localPayload)) + )); + return this; + } + + /// + /// Adds multiple activities to the branch. + /// + /// The activities to add + /// The branch builder for fluent chaining + public BranchWithLocalPayloadBuilder DoAll( + params Func>>[] activities) + { + foreach (var activity in activities) + { + _branch.Activities.Add(new BranchActivity(activity)); + } + return this; + } + + /// + /// Adds multiple synchronous activities to the branch. + /// + /// The activities to add + /// The branch builder for fluent chaining + public BranchWithLocalPayloadBuilder DoAll( + params Func>[] activities) + { + foreach (var activity in activities) + { + _branch.Activities.Add(new BranchActivity( + (mainPayload, localPayload, _) => Task.FromResult(activity(mainPayload, localPayload)) + )); + } + return this; + } +} \ No newline at end of file diff --git a/Zooper.Bee/IWorkflowStep.cs b/Zooper.Bee/IWorkflowStep.cs deleted file mode 100644 index 49d4351..0000000 --- a/Zooper.Bee/IWorkflowStep.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace Zooper.Bee; - -// ReSharper disable once UnusedType.Global -public interface IWorkflowStep; \ No newline at end of file diff --git a/Zooper.Bee/Internal/BranchActivity.cs b/Zooper.Bee/Internal/BranchActivity.cs new file mode 100644 index 0000000..0e6f7d9 --- /dev/null +++ b/Zooper.Bee/Internal/BranchActivity.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Zooper.Fox; + +namespace Zooper.Bee.Internal; + +/// +/// Represents an activity in a branch that operates on both the main workflow payload and a local branch payload. +/// +/// Type of the main workflow payload +/// Type of the local branch payload +/// Type of the error +internal sealed class BranchActivity +{ + private readonly Func>> _activity; + private readonly string? _name; + + /// + /// Creates a new branch activity. + /// + /// The activity function that operates on both payloads + /// Optional name for the activity + public BranchActivity( + Func>> activity, + string? name = null) + { + _activity = activity; + _name = name; + } + + /// + /// Executes the activity with the provided payloads. + /// + /// The main workflow payload + /// The local branch payload + /// Cancellation token + /// Either an error or the updated payloads + public Task> Execute( + TPayload mainPayload, + TLocalPayload localPayload, + CancellationToken token) + { + return _activity(mainPayload, localPayload, token); + } +} \ No newline at end of file diff --git a/Zooper.Bee/Internal/BranchWithLocalPayload.cs b/Zooper.Bee/Internal/BranchWithLocalPayload.cs new file mode 100644 index 0000000..fb1e24d --- /dev/null +++ b/Zooper.Bee/Internal/BranchWithLocalPayload.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; + +namespace Zooper.Bee.Internal; + +/// +/// Represents a branch in the workflow with its own condition, activities, and local payload. +/// +/// Type of the main workflow payload +/// Type of the local branch payload +/// Type of the error +internal sealed class BranchWithLocalPayload +{ + /// + /// The condition that determines if this branch should execute. + /// + public Func Condition { get; } + + /// + /// The factory function that creates the local payload from the main payload. + /// + public Func LocalPayloadFactory { get; } + + /// + /// The list of activities in this branch that operate on both the main and local payloads. + /// + public List> Activities { get; } = []; + + /// + /// Creates a new branch with a local payload. + /// + /// The condition that determines if this branch should execute + /// The factory function that creates the local payload + public BranchWithLocalPayload(Func condition, Func localPayloadFactory) + { + Condition = condition; + LocalPayloadFactory = localPayloadFactory; + } +} \ No newline at end of file diff --git a/Zooper.Bee/Internal/EitherExtensions.cs b/Zooper.Bee/Internal/EitherExtensions.cs index 2edd18d..0db6a5f 100644 --- a/Zooper.Bee/Internal/EitherExtensions.cs +++ b/Zooper.Bee/Internal/EitherExtensions.cs @@ -1,3 +1,4 @@ +using System; using Zooper.Fox; namespace Zooper.Bee.Internal; @@ -5,13 +6,14 @@ namespace Zooper.Bee.Internal; /// /// Provides extension methods for the class. /// -static internal class EitherExtensions +internal static class EitherExtensions { /// /// Creates a new Either instance representing a successful result (Right value). /// /// The type of the error (Left value). /// The type of the success result (Right value). + /// The Either instance this method is called on (ignored). /// The success value. /// A new Either instance with the success value in the Right position. public static Either Success(this Either _, TSuccess success) @@ -22,6 +24,7 @@ public static Either Success(this Either /// The type of the error (Left value). /// The type of the success result (Right value). + /// The Either instance this method is called on (ignored). /// The error value. /// A new Either instance with the error value in the Left position. public static Either Fail(this Either _, TError error) @@ -49,7 +52,14 @@ public static bool IsSuccess(this Either either) /// Thrown if this Either represents a failure result. /// public static TRight Value(this Either either) - => either.Right; + { + if (!either.IsRight) + { + throw new InvalidOperationException("Cannot access the Right value of an Either that contains a Left value."); + } + + return either.Right; + } /// /// Gets the error value if this Either represents a failure result, or @@ -63,5 +73,12 @@ public static TRight Value(this Either either) /// Thrown if this Either represents a success result. /// public static TLeft Error(this Either either) - => either.Left; + { + if (!either.IsLeft) + { + throw new InvalidOperationException("Cannot access the Left value of an Either that contains a Right value."); + } + + return either.Left; + } } \ No newline at end of file diff --git a/Zooper.Bee/Properties/AssemblyInfo.cs b/Zooper.Bee/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..cd6dde2 --- /dev/null +++ b/Zooper.Bee/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +// Make internal classes visible to the test project +[assembly: InternalsVisibleTo("Zooper.Bee.Tests")] \ No newline at end of file diff --git a/Zooper.Bee/WorkflowBuilder.cs b/Zooper.Bee/WorkflowBuilder.cs index ded3732..da566c0 100644 --- a/Zooper.Bee/WorkflowBuilder.cs +++ b/Zooper.Bee/WorkflowBuilder.cs @@ -29,6 +29,7 @@ public sealed class WorkflowBuilder private readonly List> _conditionalActivities = []; private readonly List> _finallyActivities = []; private readonly List> _branches = []; + private readonly List _branchesWithLocalPayload = []; /// /// Initializes a new instance of the class. @@ -245,6 +246,62 @@ public WorkflowBuilder Finally( return this; } + /// + /// Creates a branch in the workflow with a local payload that will only execute if the condition is true. + /// + /// The type of the local branch payload + /// The condition to evaluate + /// The factory function that creates the local payload + /// A branch builder that allows adding activities to the branch + public BranchWithLocalPayloadBuilder BranchWithLocalPayload( + Func condition, + Func localPayloadFactory) + { + var branch = new BranchWithLocalPayload(condition, localPayloadFactory); + _branchesWithLocalPayload.Add(branch); + return new BranchWithLocalPayloadBuilder(this, branch); + } + + /// + /// Creates a branch in the workflow with a local payload that will only execute if the condition is true. + /// + /// The type of the local branch payload + /// The condition to evaluate + /// The factory function that creates the local payload + /// An action that configures the branch + /// The workflow builder to continue the workflow definition + public WorkflowBuilder BranchWithLocalPayload( + Func condition, + Func localPayloadFactory, + Action> branchConfiguration) + { + var branch = new BranchWithLocalPayload(condition, localPayloadFactory); + _branchesWithLocalPayload.Add(branch); + var branchBuilder = new BranchWithLocalPayloadBuilder(this, branch); + branchConfiguration(branchBuilder); + return this; + } + + /// + /// Creates a branch in the workflow with a local payload that always executes. + /// This is a convenience method for organizing related activities. + /// + /// The type of the local branch payload + /// The factory function that creates the local payload + /// An action that configures the branch + /// The workflow builder to continue the workflow definition + public WorkflowBuilder BranchWithLocalPayload( + Func localPayloadFactory, + Action> branchConfiguration) + { + // Create a branch with a condition that always returns true + var branch = new BranchWithLocalPayload(_ => true, localPayloadFactory); + _branchesWithLocalPayload.Add(branch); + var branchBuilder = new BranchWithLocalPayloadBuilder(this, branch); + branchConfiguration(branchBuilder); + return this; + } + /// /// Builds a workflow that can be executed with a request of type . /// @@ -314,6 +371,18 @@ public Workflow Build() } } + // Execute branches with local payloads + foreach (var branchObj in _branchesWithLocalPayload) + { + var branchResult = await ExecuteBranchWithLocalPayloadDynamic(branchObj, payload, cancellationToken); + if (branchResult.IsLeft) + { + return Either.FromLeft(branchResult.Left); + } + + payload = branchResult.Right; + } + // Create success result var success = _resultSelector(payload); return Either.FromRight(success); @@ -330,4 +399,62 @@ public Workflow Build() } ); } + + // Dynamic helper to handle branches with different local payload types + private async Task> ExecuteBranchWithLocalPayloadDynamic( + object branchObj, + TPayload payload, + CancellationToken cancellationToken) + { + // Use reflection to call the appropriate generic method + var branchType = branchObj.GetType(); + if (branchType.IsGenericType && + branchType.GetGenericTypeDefinition() == typeof(BranchWithLocalPayload<,,>)) + { + var typeArgs = branchType.GetGenericArguments(); + var localPayloadType = typeArgs[1]; + + // Get the generic method and make it specific to the local payload type + var method = GetType().GetMethod(nameof(ExecuteBranchWithLocalPayload), + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var genericMethod = method!.MakeGenericMethod(localPayloadType); + + // Invoke the method with the right generic parameter + return (Either)await (Task>) + genericMethod.Invoke(this, new[] { branchObj, payload, cancellationToken })!; + } + + // If branch type isn't recognized, just return the payload unchanged + return Either.FromRight(payload); + } + + // Helper method to execute a branch with local payload + private async Task> ExecuteBranchWithLocalPayload( + BranchWithLocalPayload branch, + TPayload payload, + CancellationToken cancellationToken) + { + if (!branch.Condition(payload)) + { + return Either.FromRight(payload); + } + + // Create the local payload + var localPayload = branch.LocalPayloadFactory(payload); + + // Execute the branch activities + foreach (var activity in branch.Activities) + { + var activityResult = await activity.Execute(payload, localPayload, cancellationToken); + if (activityResult.IsLeft) + { + return Either.FromLeft(activityResult.Left); + } + + // Update both payloads + (payload, localPayload) = activityResult.Right; + } + + return Either.FromRight(payload); + } } From cb1d00e9198d01c7bfed41c6e9ae3d473c0d808e Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 21 Apr 2025 11:46:32 +0200 Subject: [PATCH 2/2] dotnet version fix --- Zooper.Bee.Tests/Zooper.Bee.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Zooper.Bee.Tests/Zooper.Bee.Tests.csproj b/Zooper.Bee.Tests/Zooper.Bee.Tests.csproj index dbb0d00..7e85112 100644 --- a/Zooper.Bee.Tests/Zooper.Bee.Tests.csproj +++ b/Zooper.Bee.Tests/Zooper.Bee.Tests.csproj @@ -1,7 +1,7 @@  - net6.0 + net8.0 latest enable false