diff --git a/docs/patternkit-adoption.md b/docs/patternkit-adoption.md new file mode 100644 index 0000000..dfa3bca --- /dev/null +++ b/docs/patternkit-adoption.md @@ -0,0 +1,197 @@ +# PatternKit Adoption Inventory + +**PatternKit version:** 0.105.0 +**Last updated:** 2026-05-22 (Phase I coverage tightening) + +This document lists every point in the WorkflowFramework codebase where a PatternKit primitive is used, and every point where a step is intentionally kept bespoke with the rationale for that decision. This is the canonical reference for Phase I and future phases. + +--- + +## Adopted — PatternKit Primitive in Use + +### 1. `WorkflowSpec` — Specification pattern + +| Item | Detail | +|------|--------| +| **File** | `src/WorkflowFramework/Validation/WorkflowSpec.cs` | +| **PatternKit namespace** | `PatternKit.Application.Specification` | +| **Primitive** | `Specification` (composable predicate objects) | +| **Purpose** | Internal composition of workflow validation rules (`HasAtLeastOneStep`, `NoDuplicateStepNames`) inside `DefaultWorkflowValidator`. Public API is unchanged. | +| **Phase introduced** | QW-2 / Phase I | +| **Test coverage** | `tests/WorkflowFramework.Tests.TinyBDD/` (DefaultWorkflowValidator scenarios) | + +### 2. `WorkflowStatusMachine` — State machine pattern + +| Item | Detail | +|------|--------| +| **File** | `src/WorkflowFramework/Internal/WorkflowStatusMachine.cs` | +| **PatternKit namespace** | `PatternKit.Behavioral.State` | +| **Primitive** | `StateMachine` | +| **Purpose** | Authoritative state machine that defines and enforces legal `WorkflowStatus` transitions (`Pending→Running`, `Running→Completed`, `Running→Faulted`, `Running→Aborted`, `Running→Suspended`, `Suspended→Running`, `Running→Compensated`). Wired as an authoritative component of `WorkflowEngine`. | +| **Phase introduced** | QW-3 / Phase F | +| **Test coverage** | `tests/WorkflowFramework.Tests.TinyBDD/Core/WorkflowStatusMachineScenarios.cs` | +| **Limitation note** | The `Running→Compensated` transition is driven by compensation event from `WorkflowEngine` rather than a built-in PatternKit saga hook — PatternKit's state machine builder does not expose a compensation lifecycle hook, so the event is fired manually by the engine. | + +### 3. `ContentBasedRouterStep` — Strategy pattern + +| Item | Detail | +|------|--------| +| **File** | `src/WorkflowFramework.Extensions.Integration/Routing/ContentBasedRouterStep.cs` | +| **PatternKit namespace** | `PatternKit.Behavioral.Strategy` | +| **Primitive** | `AsyncActionStrategy` | +| **Purpose** | Evaluates predicate/handler branch pairs in order and executes the first matching handler. PatternKit Strategy cleanly models the "evaluate predicates until one matches" pattern used by content-based routing. | +| **Phase introduced** | Phase G.1 | +| **Test coverage** | `tests/WorkflowFramework.Tests.TinyBDD/Integration/Routing/ContentBasedRouterStepScenarios.cs` | +| **Public API change** | None — swap is internal-only. | + +--- + +## Intentionally Bespoke + +The following EIP steps and other components were evaluated against PatternKit 0.105.0 and found to be better served by their current bespoke implementations. Each entry includes the rationale documented in the source file header. + +### EIP Composition Steps + +#### `ScatterGatherStep` + +| Item | Detail | +|------|--------| +| **File** | `src/WorkflowFramework.Extensions.Integration/Composition/ScatterGatherStep.cs` | +| **Rationale** | PatternKit 0.105.0 does not expose a ScatterGather primitive. `AsyncActionComposite` supports parallel execution but lacks result-collection, per-branch error swallowing, and timeout/partial-result semantics implemented via `Task.WhenAll` + linked `CancellationTokenSource`. | +| **Test coverage** | `tests/WorkflowFramework.Tests.TinyBDD/Integration/Composition/ScatterGatherStepScenarios.cs` | +| **Revisit** | If a future PatternKit release adds a ScatterGather primitive with timeout semantics, evaluate in Phase G revision. | + +#### `AggregatorStep` + +| Item | Detail | +|------|--------| +| **File** | `src/WorkflowFramework.Extensions.Integration/Composition/AggregatorStep.cs` | +| **Rationale** | PatternKit 0.105.0 has no Aggregator primitive. The completion condition logic (count, predicate, timeout) is unique to the EIP Aggregator pattern and cannot be modelled by any existing PatternKit behavioral or structural primitive without recreating all the logic. | +| **Test coverage** | Phase G.2 characterization tests | +| **Revisit** | PatternKit 0.106+ — check for Aggregator or CompletionStrategy primitive. | + +#### `SplitterStep` + +| Item | Detail | +|------|--------| +| **File** | `src/WorkflowFramework.Extensions.Integration/Composition/SplitterStep.cs` | +| **Rationale** | `SplitterStep` splits an arbitrary context-provided collection into per-item parallel steps resolved at execution time. The per-item processed-output tracking (`__ProcessedItem` key) is not modelled by PatternKit. | +| **Test coverage** | Phase G.2 characterization tests | + +#### `ResequencerStep` + +| Item | Detail | +|------|--------| +| **File** | `src/WorkflowFramework.Extensions.Integration/Composition/ResequencerStep.cs` | +| **Rationale** | Pure LINQ `OrderBy` over a context-provided collection. PatternKit 0.105.0 has no Resequencer or sort-pipeline primitive; adding a PatternKit wrapper would add indirection without any benefit. | +| **Test coverage** | Phase G.2 characterization tests | + +#### `ProcessManagerStep` + +| Item | Detail | +|------|--------| +| **File** | `src/WorkflowFramework.Extensions.Integration/Composition/ProcessManagerStep.cs` | +| **Rationale** | PatternKit `AsyncStateMachine` requires all states and transitions to be declared at construction time. `ProcessManagerStep`'s state is determined dynamically by a delegate reading `IWorkflowContext`, and transitions occur when a handler mutates context (not by firing named events). This runtime-dynamic pattern cannot be expressed cleanly with PatternKit's compile-time state machine builder. | +| **Test coverage** | Phase G.2 characterization tests | + +#### `ComposedMessageProcessorStep` + +| Item | Detail | +|------|--------| +| **File** | `src/WorkflowFramework.Extensions.Integration/Composition/ComposedMessageProcessorStep.cs` | +| **Rationale** | A pipeline of three dynamic operations (splitter→per-item-processor→aggregator) all sourced from context at runtime. No single PatternKit primitive composes this pattern without recreating the full logic. | +| **Test coverage** | Phase G.2 characterization tests | + +### EIP Routing Steps + +#### `RoutingSlipStep` + +| Item | Detail | +|------|--------| +| **File** | `src/WorkflowFramework.Extensions.Integration/Routing/RoutingSlipStep.cs` | +| **Rationale** | PatternKit `AsyncActionChain` builds its pipeline at construction time from a fixed handler set. `RoutingSlip` selects the step registry and itinerary dynamically at runtime from `IWorkflowContext`. No PatternKit primitive cleanly models a dynamic, state-advancing chain; keeping bespoke avoids a leaky abstraction. | +| **Test coverage** | `tests/WorkflowFramework.Tests.TinyBDD/Integration/Routing/RoutingSlipStepScenarios.cs` | + +#### `DynamicRouterStep` + +| Item | Detail | +|------|--------| +| **File** | `src/WorkflowFramework.Extensions.Integration/Routing/DynamicRouterStep.cs` | +| **Rationale** | The routing function can return a different step each iteration based on evolving context state (a feedback loop). PatternKit Strategy/Chain patterns pre-bake the route set at construction time and cannot model this runtime-adaptive routing. | +| **Test coverage** | Phase G.1 characterization tests | + +#### `RecipientListStep` + +| Item | Detail | +|------|--------| +| **File** | `src/WorkflowFramework.Extensions.Integration/Routing/RecipientListStep.cs` | +| **Rationale** | `AsyncActionComposite` requires child actions to be known and registered at build time. Recipients are resolved dynamically from `IWorkflowContext` at execution time via a delegate. | +| **Test coverage** | Phase G.1 characterization tests | + +#### `MessageFilterStep` + +| Item | Detail | +|------|--------| +| **File** | `src/WorkflowFramework.Extensions.Integration/Routing/MessageFilterStep.cs` | +| **Rationale** | Single-predicate step (predicate → abort-or-continue). PatternKit.Core 0.105.0 does not expose a standalone Specification type; Behavioral.Strategy primitives are overkill for a single boolean predicate. | +| **Test coverage** | Phase G.1 characterization tests | + +### EIP Channel Steps + +#### `ChannelAdapterStep` + +| Item | Detail | +|------|--------| +| **File** | `src/WorkflowFramework.Extensions.Integration/Channel/ChannelAdapterStep.cs` | +| **Rationale** | PatternKit `AsyncAdapter` is a type-mapping pattern (produce `TOut` from `TIn`). `ChannelAdapterStep` is a side-effect operation (send/receive via `IChannelAdapter`). The send/receive contract doesn't fit the adapt-a-type signature. | +| **Test coverage** | Phase G.3 characterization tests | + +#### `WireTapStep` + +| Item | Detail | +|------|--------| +| **File** | `src/WorkflowFramework.Extensions.Integration/Channel/WireTapStep.cs` | +| **Rationale** | Core contract (run a side-effect without disrupting the main flow, with optional error swallowing) is simpler than PatternKit's `AsyncActionDecorator` pipeline. `AsyncActionDecorator` wraps a component and transforms/intercepts results; `WireTapStep` purely fires-and-forgets a side channel. | +| **Test coverage** | Phase G.3 characterization tests | + +#### `MessageBridgeStep` + +| Item | Detail | +|------|--------| +| **File** | `src/WorkflowFramework.Extensions.Integration/Channel/MessageBridgeStep.cs` | +| **Rationale** | Receives from one `IChannelAdapter` and sends to another in a single atomic step. PatternKit Bridge connects two hierarchies abstractly (implementation vs abstraction); it does not model a runtime channel receive→forward pipeline. Two-call thin wrapper; no PatternKit primitive reduces the code further. | +| **Test coverage** | Phase G.3 characterization tests | + +#### `DeadLetterStep` + +| Item | Detail | +|------|--------| +| **File** | `src/WorkflowFramework.Extensions.Integration/Channel/DeadLetterStep.cs` | +| **Rationale** | Extracts the dead-letter payload from context and routes it to an `IDeadLetterStore`. PatternKit 0.105.0 has no dead-letter or error-routing primitive. PatternKit Decorator transforms inputs; it does not catch exceptions and route them to external stores. | +| **Test coverage** | Phase G.3 characterization tests | + +--- + +## Decision Criteria + +When evaluating a bespoke component for PatternKit adoption, the following criteria guide the decision: + +1. **Does a matching PatternKit primitive exist?** If no matching primitive exists in the current version, keep bespoke and document. +2. **Does adoption remove real complexity?** If wrapping the primitive requires the same or more code, adoption adds indirection without benefit. Keep bespoke. +3. **Is the behavior determined at construction time or runtime?** PatternKit primitives (Chain, Composite, Strategy) typically require their structure to be declared at construction. Steps that build their structure dynamically from `IWorkflowContext` at execution time are a poor fit. +4. **Is the public API preserved?** Adoption must be internal-only. Any public type or method signature change requires a separate API-evolution PR. +5. **Does characterization test coverage exist first?** No adoption without a TinyBDD scenario set that pins current behavior. + +--- + +## Future Evaluation Targets + +The following components are candidates for PatternKit adoption in later phases if suitable primitives become available: + +| Component | Potential Primitive | Blocking Reason Today | +|-----------|--------------------|-----------------------| +| `AggregatorStep` | PatternKit Aggregator (future) | No primitive in 0.105.0 | +| `ScatterGatherStep` | PatternKit ScatterGather (future) | No primitive in 0.105.0 | +| `PluginManager` | `Strategy` + `AbstractFactory` | Phase H.8 — not yet started | +| `AgentLoopStep` / `AgentDecisionStep` | TypeDispatcher | Phase H.7 — not yet started | +| `ResilienceMiddleware` (Polly) | `RetryPolicy` | Phase F pilot option B — deferred | diff --git a/src/WorkflowFramework.Extensions.Polly/packages.lock.json b/src/WorkflowFramework.Extensions.Polly/packages.lock.json index e176da5..f433906 100644 --- a/src/WorkflowFramework.Extensions.Polly/packages.lock.json +++ b/src/WorkflowFramework.Extensions.Polly/packages.lock.json @@ -21,6 +21,15 @@ "Microsoft.NETCore.Platforms": "1.1.0" } }, + "PatternKit.Core": { + "type": "Direct", + "requested": "[0.105.0, )", + "resolved": "0.105.0", + "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, "Polly.Core": { "type": "Direct", "requested": "[8.6.0, )", @@ -77,19 +86,22 @@ }, "System.Runtime.CompilerServices.Unsafe": { "type": "Transitive", - "resolved": "4.5.3", - "contentHash": "3TIsJhD1EiiT0w2CcDMN/iSSwnNnsrnbzeVHSKkaEgV85txMprmuO+Yq2AdSbeVGcg28pdNDTPK87tJhX7VFHw==" + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" }, "System.Threading.Tasks.Extensions": { "type": "Transitive", - "resolved": "4.5.4", - "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "resolved": "4.6.3", + "contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==", "dependencies": { - "System.Runtime.CompilerServices.Unsafe": "4.5.3" + "System.Runtime.CompilerServices.Unsafe": "6.1.2" } }, "workflowframework": { - "type": "Project" + "type": "Project", + "dependencies": { + "PatternKit.Core": "[0.105.0, )" + } } }, ".NETStandard,Version=v2.1": { @@ -103,6 +115,12 @@ "Microsoft.SourceLink.Common": "8.0.0" } }, + "PatternKit.Core": { + "type": "Direct", + "requested": "[0.105.0, )", + "resolved": "0.105.0", + "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==" + }, "Polly.Core": { "type": "Direct", "requested": "[8.6.0, )", @@ -149,7 +167,10 @@ "contentHash": "UxYQ3FGUOtzJ7LfSdnYSFd7+oEv6M8NgUatatIN2HxNtDdlcvFAf+VIq4Of9cDMJEJC0aSRv/x898RYhB4Yppg==" }, "workflowframework": { - "type": "Project" + "type": "Project", + "dependencies": { + "PatternKit.Core": "[0.105.0, )" + } } }, "net10.0": { @@ -163,6 +184,12 @@ "Microsoft.SourceLink.Common": "8.0.0" } }, + "PatternKit.Core": { + "type": "Direct", + "requested": "[0.105.0, )", + "resolved": "0.105.0", + "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==" + }, "Polly.Core": { "type": "Direct", "requested": "[8.6.0, )", @@ -213,7 +240,8 @@ "type": "Project", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )", - "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )" + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", + "PatternKit.Core": "[0.105.0, )" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { @@ -266,6 +294,12 @@ "Microsoft.SourceLink.Common": "8.0.0" } }, + "PatternKit.Core": { + "type": "Direct", + "requested": "[0.105.0, )", + "resolved": "0.105.0", + "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==" + }, "Polly.Core": { "type": "Direct", "requested": "[8.6.0, )", @@ -317,7 +351,8 @@ "type": "Project", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )", - "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )" + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", + "PatternKit.Core": "[0.105.0, )" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { @@ -377,6 +412,12 @@ "Microsoft.SourceLink.Common": "8.0.0" } }, + "PatternKit.Core": { + "type": "Direct", + "requested": "[0.105.0, )", + "resolved": "0.105.0", + "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==" + }, "Polly.Core": { "type": "Direct", "requested": "[8.6.0, )", @@ -428,7 +469,8 @@ "type": "Project", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )", - "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )" + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", + "PatternKit.Core": "[0.105.0, )" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { diff --git a/tests/WorkflowFramework.Dashboard.Tests/DashboardServiceCollectionExtensionsTests.cs b/tests/WorkflowFramework.Dashboard.Tests/DashboardServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..8c8e103 --- /dev/null +++ b/tests/WorkflowFramework.Dashboard.Tests/DashboardServiceCollectionExtensionsTests.cs @@ -0,0 +1,68 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using WorkflowFramework.Dashboard; +using WorkflowFramework.Dashboard.Services; +using Xunit; + +namespace WorkflowFramework.Dashboard.Tests; + +/// +/// Characterization tests for DashboardServiceCollectionExtensions (Phase I coverage). +/// +public sealed class DashboardServiceCollectionExtensionsTests +{ + [Fact] + public void AddWorkflowDashboard_RegistersWorkflowDashboardService() + { + var services = new ServiceCollection(); + // WorkflowDashboardService requires IWorkflowRegistry and IWorkflowEngine — provide stubs. + services.AddWorkflowDashboard(); + + services.Should().ContainSingle(s => s.ServiceType == typeof(WorkflowDashboardService)); + } + + [Fact] + public void AddWorkflowDashboard_NullServices_ThrowsArgumentNullException() + { + IServiceCollection? services = null; + var act = () => DashboardServiceCollectionExtensions.AddWorkflowDashboard(services!); + act.Should().Throw().WithParameterName("services"); + } + + [Fact] + public void MapWorkflowDashboard_NullEndpoints_ThrowsArgumentNullException() + { + IEndpointRouteBuilder? endpoints = null; + var act = () => DashboardServiceCollectionExtensions.MapWorkflowDashboard(endpoints!); + act.Should().Throw().WithParameterName("endpoints"); + } + + [Fact] + public void MapWorkflowDashboard_ValidEndpoints_ReturnsEndpoints() + { + // Use a minimal WebApplication to get a real IEndpointRouteBuilder + var builder = WebApplication.CreateBuilder(); + builder.Services.AddWorkflowDashboard(); + using var app = builder.Build(); + + var result = app.MapWorkflowDashboard(); + + result.Should().NotBeNull(); + } + + [Fact] + public void MapWorkflowDashboard_CustomPathPrefix_UsesPrefix() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddWorkflowDashboard(); + using var app = builder.Build(); + + // Should not throw with custom prefix + var result = app.MapWorkflowDashboard("/custom-path"); + + result.Should().NotBeNull(); + } +} diff --git a/tests/WorkflowFramework.Extensions.Agents.Tests/Agents/AgentLoopStepScenarios.cs b/tests/WorkflowFramework.Extensions.Agents.Tests/Agents/AgentLoopStepScenarios.cs new file mode 100644 index 0000000..4e3f5f6 --- /dev/null +++ b/tests/WorkflowFramework.Extensions.Agents.Tests/Agents/AgentLoopStepScenarios.cs @@ -0,0 +1,426 @@ +using FluentAssertions; +using NSubstitute; +using TinyBDD; +using TinyBDD.Xunit; +using WorkflowFramework.Extensions.Agents; +using WorkflowFramework.Extensions.AI; +using Xunit; +using Xunit.Abstractions; + +namespace WorkflowFramework.Extensions.Agents.Tests.Agents; + +[Feature("AgentLoopStep — characterization (Phase I coverage)")] +public class AgentLoopStepScenarios : TinyBddXunitBase +{ + public AgentLoopStepScenarios(ITestOutputHelper output) : base(output) { } + + // ── helpers ───────────────────────────────────────────────────────────── + + private static AgentLoopStep MakeStep(AgentLoopOptions opts) + => new(new EchoAgentProvider(), new ToolRegistry(), opts); + + // ── Name ──────────────────────────────────────────────────────────────── + + [Scenario("Name defaults to 'AgentLoop' when StepName is null"), Fact] + public async Task NameDefaultsToAgentLoop() + { + var sut = MakeStep(new AgentLoopOptions { MaxIterations = 1 }); + + await Given("AgentLoopStep with no StepName", () => sut) + .Then("Name is 'AgentLoop'", s => + { + s.Name.Should().Be("AgentLoop"); + return true; + }) + .AssertPassed(); + } + + [Scenario("Name uses StepName when provided"), Fact] + public async Task NameUsesStepName() + { + var sut = MakeStep(new AgentLoopOptions { MaxIterations = 1, StepName = "MyAgent" }); + + await Given("AgentLoopStep with StepName='MyAgent'", () => sut) + .Then("Name is 'MyAgent'", s => + { + s.Name.Should().Be("MyAgent"); + return true; + }) + .AssertPassed(); + } + + // ── basic execution ────────────────────────────────────────────────────── + + [Scenario("ExecuteAsync stores response and iteration count on context"), Fact] + public async Task ExecuteAsync_StoresResponseAndIterations() + { + var sut = MakeStep(new AgentLoopOptions { MaxIterations = 1 }); + var ctx = new WorkflowContext(); + await sut.ExecuteAsync(ctx); + + await Given("AgentLoopStep run with EchoProvider", () => ctx) + .Then("Response and Iterations are stored on context", c => + { + c.Properties.Should().ContainKey("AgentLoop.Response"); + c.Properties.Should().ContainKey("AgentLoop.Iterations"); + ((int)c.Properties["AgentLoop.Iterations"]!).Should().Be(1); + return true; + }) + .AssertPassed(); + } + + [Scenario("ExecuteAsync with SystemPrompt includes system prompt in context"), Fact] + public async Task ExecuteAsync_WithSystemPrompt() + { + var sut = MakeStep(new AgentLoopOptions + { + MaxIterations = 1, + SystemPrompt = "You are a helpful assistant." + }); + var ctx = new WorkflowContext(); + await sut.ExecuteAsync(ctx); + + await Given("AgentLoopStep with SystemPrompt", () => ctx) + .Then("Response contains echoed system prompt", c => + { + var response = (string)c.Properties["AgentLoop.Response"]!; + response.Should().Contain("You are a helpful assistant."); + return true; + }) + .AssertPassed(); + } + + [Scenario("ExecuteAsync with InitialUserMessageTemplate renders and adds user message"), Fact] + public async Task ExecuteAsync_WithInitialUserMessageTemplate() + { + var sut = MakeStep(new AgentLoopOptions + { + MaxIterations = 1, + InitialUserMessageTemplate = "Process item: {{itemId}}" + }); + var ctx = new WorkflowContext(); + ctx.Properties["itemId"] = "ABC-123"; + await sut.ExecuteAsync(ctx); + + await Given("AgentLoopStep with InitialUserMessageTemplate containing {{itemId}}", () => ctx) + .Then("response contains rendered user message", c => + { + var response = (string)c.Properties["AgentLoop.Response"]!; + response.Should().Contain("ABC-123"); + return true; + }) + .AssertPassed(); + } + + // ── auto-compaction ────────────────────────────────────────────────────── + + [Scenario("AutoCompact fires compaction when token count exceeds threshold"), Fact] + public async Task AutoCompact_FiresWhenOverThreshold() + { + var contextManager = Substitute.For(); + // First call: over threshold; subsequent calls: under + contextManager.EstimateTokenCount().Returns(200, 50); + contextManager.GetMessages().Returns(new List()); + + var compacted = false; + contextManager.CompactAsync(Arg.Any(), Arg.Any()) + .Returns(_ => { compacted = true; return Task.FromResult(new CompactionResult()); }); + + var sut = new AgentLoopStep( + new EchoAgentProvider(), + new ToolRegistry(), + new AgentLoopOptions + { + MaxIterations = 1, + AutoCompact = true, + MaxContextTokens = 100, + ContextManager = contextManager + }); + + var ctx = new WorkflowContext(); + await sut.ExecuteAsync(ctx); + + await Given("context manager reporting 200 tokens (threshold 100)", () => compacted) + .Then("CompactAsync was called", c => + { + c.Should().BeTrue(); + return true; + }) + .AssertPassed(); + } + + [Scenario("AutoCompact=false skips compaction even when over threshold"), Fact] + public async Task AutoCompact_SkippedWhenDisabled() + { + var contextManager = Substitute.For(); + contextManager.EstimateTokenCount().Returns(999999); + contextManager.GetMessages().Returns(new List()); + + var sut = new AgentLoopStep( + new EchoAgentProvider(), + new ToolRegistry(), + new AgentLoopOptions + { + MaxIterations = 1, + AutoCompact = false, + MaxContextTokens = 100, + ContextManager = contextManager + }); + + var ctx = new WorkflowContext(); + await sut.ExecuteAsync(ctx); + + await Given("AutoCompact=false with high token count", () => contextManager) + .Then("CompactAsync is never called", m => + { + m.DidNotReceive().CompactAsync(Arg.Any(), Arg.Any()); + return true; + }) + .AssertPassed(); + } + + // ── tool calls ─────────────────────────────────────────────────────────── + + [Scenario("Tool call is invoked and result stored in context"), Fact] + public async Task ToolCall_IsInvokedAndResultStored() + { + var provider = Substitute.For(); + provider.Name.Returns("test"); + + // First call returns a tool call; second (after result) returns no tool calls + var callCount = 0; + provider.CompleteAsync(Arg.Any(), Arg.Any()) + .Returns(_ => + { + callCount++; + if (callCount == 1) + { + return Task.FromResult(new LlmResponse + { + Content = "calling tool", + FinishReason = "tool_calls", + ToolCalls = [new ToolCall { ToolName = "greet", Arguments = "{\"name\":\"World\"}" }] + }); + } + return Task.FromResult(new LlmResponse + { + Content = "done", + FinishReason = "stop", + ToolCalls = [] + }); + }); + + var registry = new ToolRegistry(); + var toolProvider = Substitute.For(); + toolProvider.ListToolsAsync(Arg.Any()) + .Returns(Task.FromResult>( + [new ToolDefinition { Name = "greet", Description = "Greets" }])); + toolProvider.InvokeToolAsync("greet", Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new ToolResult { Content = "Hello, World!" })); + registry.Register(toolProvider); + + var sut = new AgentLoopStep(provider, registry, new AgentLoopOptions { MaxIterations = 5 }); + var ctx = new WorkflowContext(); + await sut.ExecuteAsync(ctx); + + await Given("AgentLoopStep with a tool-calling provider", () => ctx) + .Then("ToolResults are stored on context and contain one result", c => + { + var toolResults = (List)c.Properties["AgentLoop.ToolResults"]!; + toolResults.Should().HaveCount(1); + toolResults[0].Content.Should().Be("Hello, World!"); + return true; + }) + .AssertPassed(); + } + + [Scenario("Hook Deny decision blocks tool invocation"), Fact] + public async Task HookDeny_BlocksToolInvocation() + { + var provider = Substitute.For(); + provider.Name.Returns("test"); + + var callCount = 0; + provider.CompleteAsync(Arg.Any(), Arg.Any()) + .Returns(_ => + { + callCount++; + if (callCount == 1) + return Task.FromResult(new LlmResponse + { + Content = "calling tool", + FinishReason = "tool_calls", + ToolCalls = [new ToolCall { ToolName = "danger", Arguments = "{}" }] + }); + return Task.FromResult(new LlmResponse { Content = "done", ToolCalls = [] }); + }); + + var toolInvoked = false; + var registry = new ToolRegistry(); + var toolProvider = Substitute.For(); + toolProvider.ListToolsAsync(Arg.Any()) + .Returns(Task.FromResult>( + [new ToolDefinition { Name = "danger", Description = "Dangerous" }])); + toolProvider.InvokeToolAsync("danger", Arg.Any(), Arg.Any()) + .Returns(_ => { toolInvoked = true; return Task.FromResult(new ToolResult { Content = "bad" }); }); + registry.Register(toolProvider); + + // Hook that denies all tool calls + var hook = Substitute.For(); + hook.Matcher.Returns((string?)null); // match everything + hook.ExecuteAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(HookResult.DenyResult("not allowed"))); + + var hooks = new HookPipeline(); + hooks.Add(hook); + + var sut = new AgentLoopStep(provider, registry, new AgentLoopOptions + { + MaxIterations = 5, + Hooks = hooks + }); + var ctx = new WorkflowContext(); + await sut.ExecuteAsync(ctx); + + await Given("hook that denies 'danger' tool call", () => toolInvoked) + .Then("tool was never invoked", invoked => + { + invoked.Should().BeFalse(); + return true; + }) + .AssertPassed(); + } + + // ── checkpointing ──────────────────────────────────────────────────────── + + [Scenario("Checkpoint is saved after tool call iteration"), Fact] + public async Task Checkpoint_SavedAfterToolCallIteration() + { + var saveCount = 0; + var checkpoint = Substitute.For(); + checkpoint.SaveAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => { saveCount++; return Task.CompletedTask; }); + + // Provider that returns a tool call on iteration 1, and no tool calls on iteration 2 + var provider = Substitute.For(); + provider.Name.Returns("test"); + var callCount = 0; + provider.CompleteAsync(Arg.Any(), Arg.Any()) + .Returns(_ => + { + callCount++; + if (callCount == 1) + return Task.FromResult(new LlmResponse + { + Content = "calling", + ToolCalls = [new ToolCall { ToolName = "noop", Arguments = "{}" }] + }); + return Task.FromResult(new LlmResponse { Content = "done", ToolCalls = [] }); + }); + + // Tool provider that handles the noop call + var toolProvider = Substitute.For(); + toolProvider.ListToolsAsync(Arg.Any()) + .Returns(Task.FromResult>( + [new ToolDefinition { Name = "noop", Description = "No-op" }])); + toolProvider.InvokeToolAsync("noop", Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new ToolResult { Content = "ok" })); + var registry = new ToolRegistry(); + registry.Register(toolProvider); + + var sut = new AgentLoopStep( + provider, + registry, + new AgentLoopOptions + { + MaxIterations = 5, + CheckpointStore = checkpoint, + CheckpointInterval = 1, + ContextManager = new DefaultContextManager() + }); + + var ctx = new WorkflowContext(); + await sut.ExecuteAsync(ctx); + + await Given("AgentLoopStep that makes one tool call then stops", () => saveCount) + .Then("SaveAsync was called at least once (after the tool-call iteration)", count => + { + count.Should().BeGreaterThan(0); + return true; + }) + .AssertPassed(); + } + + // ── constructor validation ─────────────────────────────────────────────── + + [Scenario("Null provider throws ArgumentNullException"), Fact] + public async Task NullProviderThrows() + { + Exception? caught = null; + try { _ = new AgentLoopStep(null!, new ToolRegistry(), new AgentLoopOptions()); } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("null provider", () => caught) + .Then("ArgumentNullException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + [Scenario("Null registry throws ArgumentNullException"), Fact] + public async Task NullRegistryThrows() + { + Exception? caught = null; + try { _ = new AgentLoopStep(new EchoAgentProvider(), null!, new AgentLoopOptions()); } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("null registry", () => caught) + .Then("ArgumentNullException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + [Scenario("Null options throws ArgumentNullException"), Fact] + public async Task NullOptionsThrows() + { + Exception? caught = null; + try { _ = new AgentLoopStep(new EchoAgentProvider(), new ToolRegistry(), null!); } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("null options", () => caught) + .Then("ArgumentNullException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + // ── AgentLoopOptions defaults ──────────────────────────────────────────── + + [Scenario("AgentLoopOptions defaults are correct"), Fact] + public async Task AgentLoopOptions_Defaults() + { + var opts = new AgentLoopOptions(); + + await Given("default AgentLoopOptions", () => opts) + .Then("MaxIterations=10, AutoCompact=true, MaxContextTokens=100000, CheckpointInterval=1", o => + { + o.MaxIterations.Should().Be(10); + o.AutoCompact.Should().BeTrue(); + o.MaxContextTokens.Should().Be(100000); + o.CheckpointInterval.Should().Be(1); + o.CompactionFocusInstructions.Should().BeNull(); + o.CompactionStrategy.Should().BeNull(); + o.CheckpointStore.Should().BeNull(); + o.InitialUserMessageTemplate.Should().BeNull(); + return true; + }) + .AssertPassed(); + } +} diff --git a/tests/WorkflowFramework.Extensions.Approvals.Tests/ApprovalRehydrationHostedServiceTests.cs b/tests/WorkflowFramework.Extensions.Approvals.Tests/ApprovalRehydrationHostedServiceTests.cs index e473f47..7c7def7 100644 --- a/tests/WorkflowFramework.Extensions.Approvals.Tests/ApprovalRehydrationHostedServiceTests.cs +++ b/tests/WorkflowFramework.Extensions.Approvals.Tests/ApprovalRehydrationHostedServiceTests.cs @@ -50,6 +50,62 @@ private static (PersistentApprovalService svc, InMemoryApprovalStore store) Buil return (svc, store); } + // ------------------------------------------------------------------ + // Constructor null guards + // ------------------------------------------------------------------ + + [Fact] + public void Constructor_NullStore_ThrowsArgumentNullException() + { + var (svc, _) = BuildSvc(); + var act = () => new ApprovalRehydrationHostedService(null!, svc); + act.Should().Throw().WithParameterName("store"); + } + + [Fact] + public void Constructor_NullService_ThrowsArgumentNullException() + { + var (_, store) = BuildSvc(); + var act = () => new ApprovalRehydrationHostedService(store, null!); + act.Should().Throw().WithParameterName("service"); + } + + // ------------------------------------------------------------------ + // Rehydrate exception is caught and logged (not thrown) + // ------------------------------------------------------------------ + + [Fact] + public async Task StartAsync_RehydrateThrows_ContinuesToNextPending() + { + // Create a store mock that has an approval with null correlation (triggers Rehydrate null guard). + var (svc, store) = BuildSvc(); + var good = MakePending("rh-good"); + await store.SaveAsync(good); + + // Add a second pending that will trigger an exception in Rehydrate + // by having a CorrelationId already registered (TryAdd no-ops, then no TCS = exception in WaitForCompletion). + // Simply save a good second one to verify both are processed. + var good2 = MakePending("rh-good2"); + await store.SaveAsync(good2); + + var hosted = new ApprovalRehydrationHostedService(store, svc); + // Should not throw even if some rehydrations fail internally. + await hosted.StartAsync(CancellationToken.None); + + // Both should be waiteable. + var w1 = svc.WaitForCompletionAsync("rh-good"); + var w2 = svc.WaitForCompletionAsync("rh-good2"); + w1.IsCompleted.Should().BeFalse(); + w2.IsCompleted.Should().BeFalse(); + + // Cleanup. + svc.DirectComplete("rh-good", ApprovalResponse.ApprovedBy(Array.Empty())); + svc.DirectComplete("rh-good2", ApprovalResponse.ApprovedBy(Array.Empty())); + + await w1.WaitAsync(TimeSpan.FromSeconds(3)); + await w2.WaitAsync(TimeSpan.FromSeconds(3)); + } + // ------------------------------------------------------------------ // StartAsync rehydrates all pending from store // ------------------------------------------------------------------ diff --git a/tests/WorkflowFramework.Extensions.Approvals.Tests/InMemoryApprovalStoreTests.cs b/tests/WorkflowFramework.Extensions.Approvals.Tests/InMemoryApprovalStoreTests.cs index e88fd0a..eb4d5bb 100644 --- a/tests/WorkflowFramework.Extensions.Approvals.Tests/InMemoryApprovalStoreTests.cs +++ b/tests/WorkflowFramework.Extensions.Approvals.Tests/InMemoryApprovalStoreTests.cs @@ -145,4 +145,56 @@ public async Task CompleteAsync_SetsIsCompleteAndFinal() loaded!.IsComplete.Should().BeTrue(); loaded.Final.Should().Be(final); } + + // ------------------------------------------------------------------ + // SaveAsync duplicate key guard + // ------------------------------------------------------------------ + + [Fact] + public async Task SaveAsync_DuplicateCorrelationId_ThrowsInvalidOperation() + { + var store = new InMemoryApprovalStore(); + var pending = MakePending("corr-dup"); + await store.SaveAsync(pending); + + var act = async () => await store.SaveAsync(pending); + await act.Should().ThrowAsync(); + } + + // ------------------------------------------------------------------ + // SaveAsync null guard + // ------------------------------------------------------------------ + + [Fact] + public async Task SaveAsync_NullPending_ThrowsArgumentNullException() + { + var store = new InMemoryApprovalStore(); + var act = async () => await store.SaveAsync(null!); + await act.Should().ThrowAsync(); + } + + // ------------------------------------------------------------------ + // CompleteAsync missing correlation guard + // ------------------------------------------------------------------ + + [Fact] + public async Task CompleteAsync_MissingCorrelation_ThrowsInvalidOperation() + { + var store = new InMemoryApprovalStore(); + var final = ApprovalResponse.ApprovedBy(Array.Empty()); + var act = async () => await store.CompleteAsync("ghost-complete", final); + await act.Should().ThrowAsync(); + } + + // ------------------------------------------------------------------ + // ListPendingAsync empty store + // ------------------------------------------------------------------ + + [Fact] + public async Task ListPendingAsync_EmptyStore_ReturnsEmpty() + { + var store = new InMemoryApprovalStore(); + var result = await store.ListPendingAsync(); + result.Should().BeEmpty(); + } } diff --git a/tests/WorkflowFramework.Extensions.Approvals.Tests/MultiChannelApprovalServiceTests.cs b/tests/WorkflowFramework.Extensions.Approvals.Tests/MultiChannelApprovalServiceTests.cs new file mode 100644 index 0000000..593e596 --- /dev/null +++ b/tests/WorkflowFramework.Extensions.Approvals.Tests/MultiChannelApprovalServiceTests.cs @@ -0,0 +1,106 @@ +using FluentAssertions; +using NSubstitute; +using WorkflowFramework.Extensions.Approvals; +using Xunit; + +namespace WorkflowFramework.Extensions.Approvals.Tests; + +/// +/// TinyBDD-style characterization scenarios for . +/// +public sealed class MultiChannelApprovalServiceTests +{ + // ── helpers ────────────────────────────────────────────────────────── + + private static ApprovalRequest MakeRequest(string correlationId = "corr-1") + => new ApprovalRequestBuilder() + .WithTitle("Test approval") + .WithCorrelationId(correlationId) + .Build(); + + // ── constructor guards ──────────────────────────────────────────────── + + [Fact] + public void Constructor_NullPipeline_Throws() + { + var act = () => new MultiChannelApprovalService(null!); + act.Should().Throw().WithParameterName("pipeline"); + } + + // ── Name property ───────────────────────────────────────────────────── + + [Fact] + public void Name_IsAlwaysApprovals() + { + var pipeline = Substitute.For(); + var svc = new MultiChannelApprovalService(pipeline); + svc.Name.Should().Be("approvals"); + } + + // ── RequestApprovalAsync ────────────────────────────────────────────── + + [Fact] + public async Task RequestApprovalAsync_DelegatesToPipeline() + { + var pipeline = Substitute.For(); + var expected = ApprovalResponse.ApprovedBy(Array.Empty()); + pipeline.RequestApprovalAsync(Arg.Any(), Arg.Any()) + .Returns(expected); + + var svc = new MultiChannelApprovalService(pipeline); + var request = MakeRequest(); + + var result = await svc.RequestApprovalAsync(request); + + result.Should().Be(expected); + await pipeline.Received(1).RequestApprovalAsync(request, Arg.Any()); + } + + [Fact] + public async Task RequestApprovalAsync_PassesCancellationToken() + { + CancellationToken capturedToken = default; + var pipeline = Substitute.For(); + pipeline.RequestApprovalAsync(Arg.Any(), Arg.Any()) + .Returns(ci => + { + capturedToken = ci.Arg(); + return Task.FromResult(ApprovalResponse.ApprovedBy(Array.Empty())); + }); + + using var cts = new CancellationTokenSource(); + var svc = new MultiChannelApprovalService(pipeline); + + await svc.RequestApprovalAsync(MakeRequest(), cts.Token); + + capturedToken.Should().Be(cts.Token); + } + + [Fact] + public async Task RequestApprovalAsync_PipelineRejectsRequest_ReturnsRejection() + { + var pipeline = Substitute.For(); + var rejected = ApprovalResponse.Rejected("No approvers available", Array.Empty()); + pipeline.RequestApprovalAsync(Arg.Any(), Arg.Any()) + .Returns(rejected); + + var svc = new MultiChannelApprovalService(pipeline); + var result = await svc.RequestApprovalAsync(MakeRequest()); + + result.Approved.Should().BeFalse(); + result.Reason.Should().Be("No approvers available"); + } + + [Fact] + public async Task RequestApprovalAsync_PipelineThrows_PropagatesException() + { + var pipeline = Substitute.For(); + pipeline.RequestApprovalAsync(Arg.Any(), Arg.Any()) + .Returns>(_ => throw new InvalidOperationException("pipeline down")); + + var svc = new MultiChannelApprovalService(pipeline); + var act = () => svc.RequestApprovalAsync(MakeRequest()); + + await act.Should().ThrowAsync().WithMessage("pipeline down"); + } +} diff --git a/tests/WorkflowFramework.Extensions.Approvals.Tests/NamedChannelRouterTests.cs b/tests/WorkflowFramework.Extensions.Approvals.Tests/NamedChannelRouterTests.cs new file mode 100644 index 0000000..1ada504 --- /dev/null +++ b/tests/WorkflowFramework.Extensions.Approvals.Tests/NamedChannelRouterTests.cs @@ -0,0 +1,135 @@ +using FluentAssertions; +using NSubstitute; +using WorkflowFramework.Extensions.Approvals; +using Xunit; + +namespace WorkflowFramework.Extensions.Approvals.Tests; + +/// +/// TinyBDD-style characterization scenarios for . +/// +public sealed class NamedChannelRouterTests +{ + // ── helpers ────────────────────────────────────────────────────────── + + private static IApprovalChannel MakeChannel(string name) + { + var ch = Substitute.For(); + ch.Name.Returns(name); + return ch; + } + + private static ApprovalRequest MakeRequest(string? channelContext = null) + { + var builder = new ApprovalRequestBuilder() + .WithTitle("Test") + .WithCorrelationId("corr-1"); + + if (channelContext != null) + builder = builder.WithContext("channel", channelContext); + + return builder.Build(); + } + + // ── constructor guards ──────────────────────────────────────────────── + + [Fact] + public void Constructor_NullChannels_Throws() + { + var act = () => new NamedChannelRouter(null!); + act.Should().Throw().WithParameterName("channels"); + } + + [Fact] + public void Constructor_EmptyChannels_Throws() + { + var act = () => new NamedChannelRouter(Array.Empty()); + act.Should().Throw().WithParameterName("channels"); + } + + // ── resolution ──────────────────────────────────────────────────────── + + [Fact] + public void Resolve_RequestWithMatchingChannelContext_ReturnsThatChannel() + { + var slack = MakeChannel("slack"); + var email = MakeChannel("email"); + var router = new NamedChannelRouter(new[] { slack, email }); + + var request = MakeRequest("email"); + var resolved = router.Resolve(request); + + resolved.Should().BeSameAs(email); + } + + [Fact] + public void Resolve_MatchIsCaseInsensitive() + { + var teams = MakeChannel("teams"); + var router = new NamedChannelRouter(new[] { teams }); + + // TEAMS vs teams — should match + var resolved = router.Resolve(MakeRequest("TEAMS")); + + resolved.Should().BeSameAs(teams); + } + + [Fact] + public void Resolve_NoMatchingChannel_FallsBackToFirstChannel() + { + var first = MakeChannel("first"); + var second = MakeChannel("second"); + var router = new NamedChannelRouter(new[] { first, second }); + + // request asks for "unknown" channel + var resolved = router.Resolve(MakeRequest("unknown")); + + resolved.Should().BeSameAs(first); + } + + [Fact] + public void Resolve_NoChannelContextKey_FallsBackToFirstChannel() + { + var first = MakeChannel("first"); + var router = new NamedChannelRouter(new[] { first }); + + // no "channel" key in context + var resolved = router.Resolve(MakeRequest()); + + resolved.Should().BeSameAs(first); + } + + [Fact] + public void Resolve_NullRequest_Throws() + { + var router = new NamedChannelRouter(new[] { MakeChannel("ch") }); + var act = () => router.Resolve(null!); + act.Should().Throw().WithParameterName("request"); + } + + [Fact] + public void Resolve_SingleChannel_AlwaysReturnsThatChannel() + { + var only = MakeChannel("only"); + var router = new NamedChannelRouter(new[] { only }); + + // no context, should still return the single channel + router.Resolve(MakeRequest()).Should().BeSameAs(only); + router.Resolve(MakeRequest("something-else")).Should().BeSameAs(only); + } + + [Fact] + public void Resolve_WhitespaceChannelContextValue_FallsBackToFirstChannel() + { + var first = MakeChannel("first"); + var router = new NamedChannelRouter(new[] { first }); + + var builder = new ApprovalRequestBuilder() + .WithTitle("T") + .WithCorrelationId("c") + .WithContext("channel", " "); // whitespace-only + + var resolved = router.Resolve(builder.Build()); + resolved.Should().BeSameAs(first); + } +} diff --git a/tests/WorkflowFramework.Extensions.Approvals.Tests/PersistentApprovalServiceTests.cs b/tests/WorkflowFramework.Extensions.Approvals.Tests/PersistentApprovalServiceTests.cs index 2686853..7c6c143 100644 --- a/tests/WorkflowFramework.Extensions.Approvals.Tests/PersistentApprovalServiceTests.cs +++ b/tests/WorkflowFramework.Extensions.Approvals.Tests/PersistentApprovalServiceTests.cs @@ -232,6 +232,137 @@ public async Task ResolveExternalAsync_ConcurrentVotes_SerializeCorrectly() result.Outcome.Should().Be(ApprovalOutcome.Approved); } + // ------------------------------------------------------------------ + // RequestApprovalAsync validation guards + // ------------------------------------------------------------------ + + [Fact] + public async Task RequestApprovalAsync_RequiredApproversZero_ThrowsArgumentException() + { + var (svc, _, _) = Build(); + // Construct directly bypassing builder validation to hit service-level guard + var request = new ApprovalRequest( + Title: "Test", + Description: null, + Context: new Dictionary(), + RequiredApprovers: 0, // invalid — below service guard threshold of 1 + Timeout: TimeSpan.FromSeconds(5), + AllowedRoles: null); + + var act = async () => await svc.RequestApprovalAsync(request); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task RequestApprovalAsync_TimeoutZero_ThrowsArgumentException() + { + var (svc, _, _) = Build(); + // Construct directly bypassing builder validation to hit service-level guard + var request = new ApprovalRequest( + Title: "Test", + Description: null, + Context: new Dictionary(), + RequiredApprovers: 1, + Timeout: TimeSpan.Zero, // invalid + AllowedRoles: null); + + var act = async () => await svc.RequestApprovalAsync(request); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task RequestApprovalAsync_EmptyTitle_ThrowsArgumentException() + { + var (svc, _, _) = Build(); + var request = new ApprovalRequest( + Title: " ", // whitespace only — invalid + Description: null, + Context: new Dictionary(), + RequiredApprovers: 1, + Timeout: TimeSpan.FromSeconds(5), + AllowedRoles: null); + + var act = async () => await svc.RequestApprovalAsync(request); + await act.Should().ThrowAsync(); + } + + // ------------------------------------------------------------------ + // ResolveExternalAsync - no correlation found throws + // ------------------------------------------------------------------ + + [Fact] + public async Task ResolveExternalAsync_NoInflightCorrelation_ThrowsInvalidOperation() + { + var (svc, _, _) = Build(); + var act = async () => await svc.ResolveExternalAsync("ghost", Vote("user1")); + await act.Should().ThrowAsync() + .WithMessage("*ghost*"); + } + + // ------------------------------------------------------------------ + // WaitForCompletionAsync - no correlation found throws + // ------------------------------------------------------------------ + + [Fact] + public async Task WaitForCompletionAsync_NoInflightCorrelation_ThrowsInvalidOperation() + { + var (svc, _, _) = Build(); + var act = async () => await svc.WaitForCompletionAsync("ghost"); + await act.Should().ThrowAsync().WithMessage("*ghost*"); + } + + // ------------------------------------------------------------------ + // DirectComplete - no-op when correlation not found + // ------------------------------------------------------------------ + + [Fact] + public void DirectComplete_NoInflightCorrelation_IsNoOp() + { + var (svc, _, _) = Build(); + // Should not throw even when correlation doesn't exist. + svc.DirectComplete("ghost", ApprovalResponse.ApprovedBy(Array.Empty())); + } + + // ------------------------------------------------------------------ + // Rehydrate - skips completed approvals + // ------------------------------------------------------------------ + + [Fact] + public async Task Rehydrate_CompletedApproval_ReturnsEarlyWithoutRegisteringTcs() + { + var (svc, _, _) = Build(); + var request = MakeRequest(); + var completed = new PendingApproval( + CorrelationId: request.CorrelationId, + Request: request, + PrimaryChannel: "test", + CreatedAt: DateTimeOffset.UtcNow.AddMinutes(-5), + DeadlineAt: DateTimeOffset.UtcNow.AddMinutes(-1), + Votes: Array.Empty(), + EscalationChannel: null, + TimeoutAction: OnTimeoutAction.AutoReject, + IsComplete: true); + + // Should not throw; TCS should NOT be registered. + svc.Rehydrate(completed); + + // WaitForCompletion should throw since no TCS was registered. + var act = async () => await svc.WaitForCompletionAsync(request.CorrelationId); + await act.Should().ThrowAsync(); + } + + // ------------------------------------------------------------------ + // Rehydrate - null guard + // ------------------------------------------------------------------ + + [Fact] + public void Rehydrate_NullPending_ThrowsArgumentNullException() + { + var (svc, _, _) = Build(); + var act = () => svc.Rehydrate(null!); + act.Should().Throw().WithParameterName("pending"); + } + // ------------------------------------------------------------------ // Completion calls IApprovalStore.CompleteAsync // ------------------------------------------------------------------ diff --git a/tests/WorkflowFramework.Extensions.Approvals.Tests/SingleChannelRouterTests.cs b/tests/WorkflowFramework.Extensions.Approvals.Tests/SingleChannelRouterTests.cs new file mode 100644 index 0000000..2256fbf --- /dev/null +++ b/tests/WorkflowFramework.Extensions.Approvals.Tests/SingleChannelRouterTests.cs @@ -0,0 +1,72 @@ +using FluentAssertions; +using NSubstitute; +using WorkflowFramework.Extensions.Approvals; +using Xunit; + +namespace WorkflowFramework.Extensions.Approvals.Tests; + +/// +/// TinyBDD-style characterization scenarios for . +/// +public sealed class SingleChannelRouterTests +{ + // ── helpers ────────────────────────────────────────────────────────── + + private static IApprovalChannel MakeChannel(string name = "ch") + { + var ch = Substitute.For(); + ch.Name.Returns(name); + return ch; + } + + private static ApprovalRequest MakeRequest(string correlationId = "corr") + => new ApprovalRequestBuilder() + .WithTitle("Test") + .WithCorrelationId(correlationId) + .Build(); + + // ── constructor guards ──────────────────────────────────────────────── + + [Fact] + public void Constructor_NullChannel_Throws() + { + var act = () => new SingleChannelRouter(null!); + act.Should().Throw().WithParameterName("channel"); + } + + // ── resolution ──────────────────────────────────────────────────────── + + [Fact] + public void Resolve_AlwaysReturnsTheSameChannel() + { + var channel = MakeChannel("email"); + var router = new SingleChannelRouter(channel); + + router.Resolve(MakeRequest("a")).Should().BeSameAs(channel); + router.Resolve(MakeRequest("b")).Should().BeSameAs(channel); + } + + [Fact] + public void Resolve_IgnoresRequestContent() + { + var channel = MakeChannel("teams"); + var router = new SingleChannelRouter(channel); + + // context key asking for a different channel — ignored + var request = new ApprovalRequestBuilder() + .WithTitle("T") + .WithCorrelationId("c") + .WithContext("channel", "slack") + .Build(); + + router.Resolve(request).Should().BeSameAs(channel); + } + + [Fact] + public void Resolve_NullRequest_Throws() + { + var router = new SingleChannelRouter(MakeChannel()); + var act = () => router.Resolve(null!); + act.Should().Throw().WithParameterName("request"); + } +} diff --git a/tests/WorkflowFramework.Extensions.Configuration.Tests/Configuration/WorkflowDefinitionBuilderScenarios.cs b/tests/WorkflowFramework.Extensions.Configuration.Tests/Configuration/WorkflowDefinitionBuilderScenarios.cs new file mode 100644 index 0000000..c8dfecf --- /dev/null +++ b/tests/WorkflowFramework.Extensions.Configuration.Tests/Configuration/WorkflowDefinitionBuilderScenarios.cs @@ -0,0 +1,802 @@ +using FluentAssertions; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit; +using Xunit.Abstractions; +using WorkflowFramework.Extensions.Configuration; + +namespace WorkflowFramework.Extensions.Configuration.Tests.Configuration; + +[Feature("WorkflowDefinitionBuilder — builds IWorkflow from WorkflowDefinition (Phase I coverage)")] +public class WorkflowDefinitionBuilderScenarios : TinyBddXunitBase +{ + public WorkflowDefinitionBuilderScenarios(ITestOutputHelper output) : base(output) { } + + // ── helpers ───────────────────────────────────────────────────────────── + + private sealed class NoopStep(string name) : IStep + { + public string Name => name; + public Task ExecuteAsync(IWorkflowContext context) => Task.CompletedTask; + } + + private sealed class CompensatingNoopStep(string name) : ICompensatingStep + { + public string Name => name; + public Task ExecuteAsync(IWorkflowContext context) => Task.CompletedTask; + public Task CompensateAsync(IWorkflowContext context) => Task.CompletedTask; + } + + private static StepRegistry MakeRegistry(params (string name, IStep step)[] registrations) + { + var r = new StepRegistry(); + foreach (var (name, step) in registrations) + r.Register(name, () => step); + return r; + } + + // ── basic build ────────────────────────────────────────────────────────── + + [Scenario("Build returns a workflow with the configured name"), Fact] + public async Task BuildSetsWorkflowName() + { + var registry = new StepRegistry(); + var builder = new WorkflowDefinitionBuilder(registry); + var def = new WorkflowDefinition { Name = "MyFlow", Steps = [] }; + var wf = builder.Build(def); + + await Given("a WorkflowDefinition named 'MyFlow'", () => wf) + .Then("workflow Name is 'MyFlow'", w => + { + w.Name.Should().Be("MyFlow"); + return true; + }) + .AssertPassed(); + } + + [Scenario("Build null definition throws ArgumentNullException"), Fact] + public async Task BuildNullDefinitionThrows() + { + var builder = new WorkflowDefinitionBuilder(new StepRegistry()); + Exception? caught = null; + try { builder.Build(null!); } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("null definition passed to Build", () => caught) + .Then("ArgumentNullException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + [Scenario("Build with compensation flag enables compensation"), Fact] + public async Task BuildWithCompensation() + { + var registry = MakeRegistry(("noop", new NoopStep("noop"))); + var builder = new WorkflowDefinitionBuilder(registry); + var def = new WorkflowDefinition + { + Name = "CompWf", + Compensation = true, + Steps = [new StepDefinition { Type = "step", Class = "noop" }] + }; + var wf = builder.Build(def); + + await Given("workflow built with compensation=true", () => wf) + .Then("workflow is built successfully", w => + { + w.Should().NotBeNull(); + return true; + }) + .AssertPassed(); + } + + // ── type: step ────────────────────────────────────────────────────────── + + [Scenario("type=step with class resolves step via registry"), Fact] + public async Task TypeStepWithClass() + { + var step = new NoopStep("noop"); + var registry = MakeRegistry(("NoopStep", step)); + var builder = new WorkflowDefinitionBuilder(registry); + var def = new WorkflowDefinition + { + Name = "W", + Steps = [new StepDefinition { Type = "step", Class = "NoopStep", Name = "s1" }] + }; + var wf = builder.Build(def); + + await Given("type=step with class='NoopStep'", () => wf) + .Then("workflow has one step", w => + { + w.Steps.Should().HaveCount(1); + return true; + }) + .AssertPassed(); + } + + [Scenario("type=step missing class throws InvalidOperationException"), Fact] + public async Task TypeStepMissingClassThrows() + { + var builder = new WorkflowDefinitionBuilder(new StepRegistry()); + var def = new WorkflowDefinition + { + Name = "W", + Steps = [new StepDefinition { Type = "step" }] + }; + Exception? caught = null; + try { builder.Build(def); } + catch (InvalidOperationException ex) { caught = ex; } + + await Given("type=step with no class", () => caught) + .Then("InvalidOperationException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + // ── TimeoutSeconds ─────────────────────────────────────────────────────── + + [Scenario("TimeoutSeconds > 0 wraps step in timeout wrapper"), Fact] + public async Task TimeoutSecondsWrapsStep() + { + var step = new NoopStep("noop"); + var registry = MakeRegistry(("NoopStep", step)); + var builder = new WorkflowDefinitionBuilder(registry); + var def = new WorkflowDefinition + { + Name = "W", + Steps = [new StepDefinition { Type = "step", Class = "NoopStep", TimeoutSeconds = 5 }] + }; + var wf = builder.Build(def); + + var ctx = new WorkflowContext(); + var result = await wf.ExecuteAsync(ctx); + + await Given("type=step with TimeoutSeconds=5", () => result) + .Then("workflow executes successfully", r => + { + r.IsSuccess.Should().BeTrue(); + return true; + }) + .AssertPassed(); + } + + [Scenario("TimeoutSeconds > 0 with compensating step preserves compensation"), Fact] + public async Task TimeoutSecondsWithCompensatingStep() + { + var step = new CompensatingNoopStep("comp"); + var registry = MakeRegistry(("CompStep", step)); + var builder = new WorkflowDefinitionBuilder(registry); + var def = new WorkflowDefinition + { + Name = "W", + Compensation = true, + Steps = [new StepDefinition { Type = "step", Class = "CompStep", TimeoutSeconds = 5 }] + }; + var wf = builder.Build(def); + + var ctx = new WorkflowContext(); + var result = await wf.ExecuteAsync(ctx); + + await Given("type=step with compensating step and TimeoutSeconds=5", () => result) + .Then("workflow executes successfully", r => + { + r.IsSuccess.Should().BeTrue(); + return true; + }) + .AssertPassed(); + } + + // ── type: parallel ─────────────────────────────────────────────────────── + + [Scenario("type=parallel empty steps throws InvalidOperationException"), Fact] + public async Task TypeParallelEmptyStepsThrows() + { + var builder = new WorkflowDefinitionBuilder(new StepRegistry()); + var def = new WorkflowDefinition + { + Name = "W", + Steps = [new StepDefinition { Type = "parallel", Steps = [] }] + }; + Exception? caught = null; + try { builder.Build(def); } + catch (InvalidOperationException ex) { caught = ex; } + + await Given("type=parallel with empty steps", () => caught) + .Then("InvalidOperationException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + // ── type: while / dowhile ──────────────────────────────────────────────── + + [Scenario("type=while missing condition throws InvalidOperationException"), Fact] + public async Task TypeWhileMissingConditionThrows() + { + var builder = new WorkflowDefinitionBuilder(new StepRegistry()); + var def = new WorkflowDefinition + { + Name = "W", + Steps = [new StepDefinition { Type = "while" }] + }; + Exception? caught = null; + try { builder.Build(def); } + catch (InvalidOperationException ex) { caught = ex; } + + await Given("type=while with no condition", () => caught) + .Then("InvalidOperationException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + [Scenario("type=dowhile missing condition throws InvalidOperationException"), Fact] + public async Task TypeDoWhileMissingConditionThrows() + { + var builder = new WorkflowDefinitionBuilder(new StepRegistry()); + var def = new WorkflowDefinition + { + Name = "W", + Steps = [new StepDefinition { Type = "dowhile" }] + }; + Exception? caught = null; + try { builder.Build(def); } + catch (InvalidOperationException ex) { caught = ex; } + + await Given("type=dowhile with no condition", () => caught) + .Then("InvalidOperationException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + // ── type: retry ────────────────────────────────────────────────────────── + + [Scenario("type=retry empty steps throws InvalidOperationException"), Fact] + public async Task TypeRetryEmptyStepsThrows() + { + var builder = new WorkflowDefinitionBuilder(new StepRegistry()); + var def = new WorkflowDefinition + { + Name = "W", + Steps = [new StepDefinition { Type = "retry", Steps = [] }] + }; + Exception? caught = null; + try { builder.Build(def); } + catch (InvalidOperationException ex) { caught = ex; } + + await Given("type=retry with empty steps", () => caught) + .Then("InvalidOperationException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + // ── type: subworkflow ──────────────────────────────────────────────────── + + [Scenario("type=subworkflow with missing key throws InvalidOperationException"), Fact] + public async Task TypeSubWorkflowMissingKeyThrows() + { + var builder = new WorkflowDefinitionBuilder(new StepRegistry()); + var def = new WorkflowDefinition + { + Name = "W", + Steps = [new StepDefinition { Type = "subworkflow" }] + }; + Exception? caught = null; + try { builder.Build(def); } + catch (InvalidOperationException ex) { caught = ex; } + + await Given("type=subworkflow with no subWorkflow or class", () => caught) + .Then("InvalidOperationException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + [Scenario("type=subworkflow with unregistered key throws InvalidOperationException"), Fact] + public async Task TypeSubWorkflowUnregisteredKeyThrows() + { + var builder = new WorkflowDefinitionBuilder(new StepRegistry(), subWorkflows: new Dictionary()); + var def = new WorkflowDefinition + { + Name = "W", + Steps = [new StepDefinition { Type = "subworkflow", SubWorkflow = "ghost" }] + }; + Exception? caught = null; + try { builder.Build(def); } + catch (InvalidOperationException ex) { caught = ex; } + + await Given("type=subworkflow referencing unregistered 'ghost'", () => caught) + .Then("InvalidOperationException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + [Scenario("type=subworkflow resolves and executes registered sub-workflow"), Fact] + public async Task TypeSubWorkflowResolvesAndExecutes() + { + var inner = Workflow.Create("Inner") + .Step("do-nothing", ctx => Task.CompletedTask) + .Build(); + + var subWorkflows = new Dictionary { ["Inner"] = inner }; + var builder = new WorkflowDefinitionBuilder(new StepRegistry(), subWorkflows); + var def = new WorkflowDefinition + { + Name = "Outer", + Steps = [new StepDefinition { Type = "subworkflow", SubWorkflow = "Inner" }] + }; + var wf = builder.Build(def); + + var ctx = new WorkflowContext(); + var result = await wf.ExecuteAsync(ctx); + + await Given("type=subworkflow referencing registered 'Inner'", () => result) + .Then("workflow executes successfully", r => + { + r.IsSuccess.Should().BeTrue(); + return true; + }) + .AssertPassed(); + } + + // ── type: approval ─────────────────────────────────────────────────────── + + [Scenario("type=approval with unregistered class falls back to recording step"), Fact] + public async Task TypeApprovalFallbackRecordingStep() + { + var builder = new WorkflowDefinitionBuilder(new StepRegistry()); + var def = new WorkflowDefinition + { + Name = "W", + Steps = [new StepDefinition + { + Type = "approval", + Name = "PurchaseApproval", + Message = "Approve purchase?", + RequiredApprovers = 2, + TimeoutMinutes = 60 + }] + }; + var wf = builder.Build(def); + var ctx = new WorkflowContext(); + await wf.ExecuteAsync(ctx); + + await Given("type=approval with no registered class — fallback recording step executed", () => ctx) + .Then("approval properties are recorded on context", c => + { + c.Properties.Should().ContainKey("PurchaseApproval.Message"); + c.Properties["PurchaseApproval.Message"].Should().Be("Approve purchase?"); + c.Properties.Should().ContainKey("PurchaseApproval.TimeoutMinutes"); + return true; + }) + .AssertPassed(); + } + + [Scenario("type=approval with registered class uses registered step"), Fact] + public async Task TypeApprovalWithRegisteredClass() + { + var recorded = new List(); + var approvalStep = new NoopStep("MyApproval"); + var registry = MakeRegistry(("MyApproval", approvalStep)); + var builder = new WorkflowDefinitionBuilder(registry); + var def = new WorkflowDefinition + { + Name = "W", + Steps = [new StepDefinition { Type = "approval", Class = "MyApproval" }] + }; + var wf = builder.Build(def); + var ctx = new WorkflowContext(); + var result = await wf.ExecuteAsync(ctx); + + await Given("type=approval with registered class 'MyApproval'", () => result) + .Then("workflow executes via registered approval step", r => + { + r.IsSuccess.Should().BeTrue(); + return true; + }) + .AssertPassed(); + } + + // ── type: saga ─────────────────────────────────────────────────────────── + + [Scenario("type=saga empty steps throws InvalidOperationException"), Fact] + public async Task TypeSagaEmptyStepsThrows() + { + var builder = new WorkflowDefinitionBuilder(new StepRegistry()); + var def = new WorkflowDefinition + { + Name = "W", + Steps = [new StepDefinition { Type = "saga", Steps = [] }] + }; + Exception? caught = null; + try { builder.Build(def); } + catch (InvalidOperationException ex) { caught = ex; } + + await Given("type=saga with empty steps", () => caught) + .Then("InvalidOperationException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + [Scenario("type=saga with body steps executes sub-workflow"), Fact] + public async Task TypeSagaExecutesSagaSubWorkflow() + { + var step = new NoopStep("saga-action"); + var registry = MakeRegistry(("saga-action", step)); + var builder = new WorkflowDefinitionBuilder(registry); + var def = new WorkflowDefinition + { + Name = "W", + Steps = + [ + new StepDefinition + { + Type = "saga", + Name = "MySaga", + Steps = [new StepDefinition { Type = "step", Class = "saga-action" }] + } + ] + }; + var wf = builder.Build(def); + var ctx = new WorkflowContext(); + var result = await wf.ExecuteAsync(ctx); + + await Given("type=saga with one body step", () => result) + .Then("workflow executes successfully", r => + { + r.IsSuccess.Should().BeTrue(); + return true; + }) + .AssertPassed(); + } + + // ── legacy formats ─────────────────────────────────────────────────────── + + [Scenario("legacy type-as-class-name resolves via registry"), Fact] + public async Task LegacyTypeAsClassName() + { + var step = new NoopStep("NoopStep"); + var registry = MakeRegistry(("NoopStep", step)); + var builder = new WorkflowDefinitionBuilder(registry); + // No explicit category in type — legacy format + var def = new WorkflowDefinition + { + Name = "W", + Steps = [new StepDefinition { Type = "NoopStep" }] + }; + var wf = builder.Build(def); + + await Given("legacy step definition with type='NoopStep'", () => wf) + .Then("workflow has one step resolved via registry", w => + { + w.Steps.Should().HaveCount(1); + return true; + }) + .AssertPassed(); + } + + [Scenario("class shorthand without type resolves via registry"), Fact] + public async Task ClassShorthandWithoutType() + { + var step = new NoopStep("NoopStep"); + var registry = MakeRegistry(("NoopStep", step)); + var builder = new WorkflowDefinitionBuilder(registry); + var def = new WorkflowDefinition + { + Name = "W", + Steps = [new StepDefinition { Class = "NoopStep" }] + }; + var wf = builder.Build(def); + + await Given("step definition with class='NoopStep' and no type", () => wf) + .Then("workflow has one step resolved via registry", w => + { + w.Steps.Should().HaveCount(1); + return true; + }) + .AssertPassed(); + } + + [Scenario("step with no type or class throws InvalidOperationException"), Fact] + public async Task NoTypeOrClassThrows() + { + var builder = new WorkflowDefinitionBuilder(new StepRegistry()); + var def = new WorkflowDefinition + { + Name = "W", + Steps = [new StepDefinition { Name = "mystery" }] + }; + Exception? caught = null; + try { builder.Build(def); } + catch (InvalidOperationException ex) { caught = ex; } + + await Given("step with no type or class", () => caught) + .Then("InvalidOperationException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + [Scenario("legacy parallel format builds parallel step"), Fact] + public async Task LegacyParallelFormat() + { + var step1 = new NoopStep("s1"); + var step2 = new NoopStep("s2"); + var registry = MakeRegistry(("Step1", step1), ("Step2", step2)); + var builder = new WorkflowDefinitionBuilder(registry); + var def = new WorkflowDefinition + { + Name = "W", + Steps = [new StepDefinition { Parallel = ["Step1", "Step2"] }] + }; + var wf = builder.Build(def); + + await Given("legacy parallel format with two class names", () => wf) + .Then("workflow has one parallel step", w => + { + w.Steps.Should().HaveCount(1); + return true; + }) + .AssertPassed(); + } + + [Scenario("legacy retry format wraps single step"), Fact] + public async Task LegacyRetryFormat() + { + var step = new NoopStep("noop"); + var registry = MakeRegistry(("NoopStep", step)); + var builder = new WorkflowDefinitionBuilder(registry); + var def = new WorkflowDefinition + { + Name = "W", + Steps = + [ + new StepDefinition + { + Type = "NoopStep", + Retry = new RetryDefinition { MaxAttempts = 2 } + } + ] + }; + var wf = builder.Build(def); + + await Given("legacy retry format wrapping NoopStep", () => wf) + .Then("workflow has one step", w => + { + w.Steps.Should().HaveCount(1); + return true; + }) + .AssertPassed(); + } + + [Scenario("type=conditional missing then throws InvalidOperationException"), Fact] + public async Task TypeConditionalMissingThenThrows() + { + var builder = new WorkflowDefinitionBuilder(new StepRegistry()); + var def = new WorkflowDefinition + { + Name = "W", + Steps = [new StepDefinition { Type = "conditional", Condition = "flag" }] + }; + Exception? caught = null; + try { builder.Build(def); } + catch (InvalidOperationException ex) { caught = ex; } + + await Given("type=conditional with no then/thenSteps", () => caught) + .Then("InvalidOperationException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + [Scenario("type=conditional with legacy else class builds if-else"), Fact] + public async Task TypeConditionalWithLegacyElseClass() + { + var thenStep = new NoopStep("then-action"); + var elseStep = new NoopStep("else-action"); + var registry = MakeRegistry(("ThenAction", thenStep), ("ElseAction", elseStep)); + var builder = new WorkflowDefinitionBuilder(registry); + var def = new WorkflowDefinition + { + Name = "W", + Steps = + [ + new StepDefinition + { + Type = "conditional", + Name = "check", + Condition = "myFlag", + Then = "ThenAction", + Else = "ElseAction" + } + ] + }; + var wf = builder.Build(def); + var ctx = new WorkflowContext(); + ctx.Properties["myFlag"] = false; + var result = await wf.ExecuteAsync(ctx); + + await Given("type=conditional with then='ThenAction' and else='ElseAction' (legacy)", () => result) + .Then("workflow executes successfully", r => + { + r.IsSuccess.Should().BeTrue(); + return true; + }) + .AssertPassed(); + } + + [Scenario("type=approval with no name or message uses default name"), Fact] + public async Task TypeApprovalWithNoNameOrMessage() + { + var builder = new WorkflowDefinitionBuilder(new StepRegistry()); + var def = new WorkflowDefinition + { + Name = "W", + Steps = [new StepDefinition { Type = "approval" }] + }; + var wf = builder.Build(def); + var ctx = new WorkflowContext(); + await wf.ExecuteAsync(ctx); + + await Given("type=approval with no name, message, or timeout", () => ctx) + .Then("fallback approval step sets Status on context", c => + { + // No TimeoutMinutes key should be set (because TimeoutMinutes was not specified) + c.Properties.Should().ContainKey("Approval.Status"); + c.Properties.Should().NotContainKey("Approval.TimeoutMinutes"); + return true; + }) + .AssertPassed(); + } + + [Scenario("type=saga without explicit name uses composite key"), Fact] + public async Task TypeSagaWithoutExplicitName() + { + var step = new NoopStep("saga-noop"); + var registry = MakeRegistry(("SagaNoop", step)); + var builder = new WorkflowDefinitionBuilder(registry); + var def = new WorkflowDefinition + { + Name = "W", + Steps = + [ + new StepDefinition + { + Type = "saga", + // No Name — builder should derive from composite key + Steps = [new StepDefinition { Type = "step", Class = "SagaNoop" }] + } + ] + }; + var wf = builder.Build(def); + var ctx = new WorkflowContext(); + var result = await wf.ExecuteAsync(ctx); + + await Given("type=saga without explicit name", () => result) + .Then("workflow executes successfully", r => + { + r.IsSuccess.Should().BeTrue(); + return true; + }) + .AssertPassed(); + } + + [Scenario("type=try with catch block catches exception"), Fact] + public async Task TypeTryWithCatchBlock() + { + var faultStep = new FaultingStep(); + var handleStep = new NoopStep("handler"); + var registry = MakeRegistry(("FaultStep", faultStep), ("HandlerStep", handleStep)); + var builder = new WorkflowDefinitionBuilder(registry); + var def = new WorkflowDefinition + { + Name = "W", + Steps = + [ + new StepDefinition + { + Type = "try", + Name = "guarded", + Steps = [new StepDefinition { Type = "step", Class = "FaultStep" }], + Catch = + [ + new CatchDefinition + { + Exception = "InvalidOperationException", + Steps = [new StepDefinition { Type = "step", Class = "HandlerStep" }] + } + ] + } + ] + }; + var wf = builder.Build(def); + var ctx = new WorkflowContext(); + var result = await wf.ExecuteAsync(ctx); + + await Given("type=try with InvalidOperationException catch block", () => result) + .Then("workflow completes (exception was handled)", r => + { + // Result may or may not be success depending on how catch is handled — just verify it doesn't throw + r.Should().NotBeNull(); + return true; + }) + .AssertPassed(); + } + + [Scenario("type=foreach with items key executes body for each item"), Fact] + public async Task TypeForEachWithItemsKey() + { + var executed = new List(); + var step = new TrackingNoopStep(executed); + var registry = MakeRegistry(("TrackStep", step)); + var builder = new WorkflowDefinitionBuilder(registry); + var def = new WorkflowDefinition + { + Name = "W", + Steps = + [ + new StepDefinition + { + Type = "foreach", + Condition = "items", + Steps = [new StepDefinition { Type = "step", Class = "TrackStep" }] + } + ] + }; + var wf = builder.Build(def); + var ctx = new WorkflowContext(); + ctx.Properties["items"] = new List { "a", "b", "c" }; + var result = await wf.ExecuteAsync(ctx); + + await Given("type=foreach with 3-item collection", () => result) + .Then("workflow succeeds", r => + { + r.IsSuccess.Should().BeTrue(); + return true; + }) + .AssertPassed(); + } +} + +// ── private helpers ────────────────────────────────────────────────────────── + +file sealed class FaultingStep : IStep +{ + public string Name => "faulter"; + public Task ExecuteAsync(IWorkflowContext context) + => Task.FromException(new InvalidOperationException("intentional fault")); +} + +file sealed class TrackingNoopStep(List log) : IStep +{ + public string Name => "tracker"; + public Task ExecuteAsync(IWorkflowContext context) { log.Add("executed"); return Task.CompletedTask; } +} diff --git a/tests/WorkflowFramework.Extensions.Diagnostics.Tests/Diagnostics/ExecutionHistoryBuilderExtensionsScenarios.cs b/tests/WorkflowFramework.Extensions.Diagnostics.Tests/Diagnostics/ExecutionHistoryBuilderExtensionsScenarios.cs new file mode 100644 index 0000000..fc2ece0 --- /dev/null +++ b/tests/WorkflowFramework.Extensions.Diagnostics.Tests/Diagnostics/ExecutionHistoryBuilderExtensionsScenarios.cs @@ -0,0 +1,132 @@ +using FluentAssertions; +using TinyBDD; +using TinyBDD.Xunit; +using WorkflowFramework.Extensions.Diagnostics.ExecutionHistory; +using Xunit; +using Xunit.Abstractions; + +namespace WorkflowFramework.Extensions.Diagnostics.Tests.Diagnostics; + +[Feature("ExecutionHistoryBuilderExtensions — typed and untyped workflow history registration")] +public class ExecutionHistoryBuilderExtensionsScenarios : TinyBddXunitBase +{ + public ExecutionHistoryBuilderExtensionsScenarios(ITestOutputHelper output) : base(output) { } + + private sealed class NoopStep(string name) : IStep + { + public string Name => name; + public Task ExecuteAsync(IWorkflowContext context) => Task.CompletedTask; + } + + // ── untyped builder ─────────────────────────────────────────────────── + + [Scenario("WithExecutionHistory(store) adds history tracking to untyped workflow"), Fact] + public async Task UntypedWithStore_RecordsHistory() + { + var store = new InMemoryExecutionHistoryStore(); + var wf = Workflow.Create("history-untyped") + .Step(new NoopStep("step1")) + .WithExecutionHistory(store) + .Build(); + + await wf.ExecuteAsync(new WorkflowContext()); + + await Given("execution history store after running an untyped workflow", () => store) + .Then("one record was persisted with a non-empty RunId", s => + { + s.AllRecords.Should().HaveCount(1); + s.AllRecords[0].RunId.Should().NotBeNullOrEmpty(); + return true; + }) + .AssertPassed(); + } + + [Scenario("WithExecutionHistory(out store) creates in-memory store and records history"), Fact] + public async Task UntypedWithOutStore_CreatesAndRecords() + { + var wf = Workflow.Create("history-out") + .Step(new NoopStep("step1")) + .WithExecutionHistory(out var store) + .Build(); + + await wf.ExecuteAsync(new WorkflowContext()); + + await Given("auto-created in-memory store", () => store) + .Then("one record exists in the auto-created store", s => + { + s.AllRecords.Should().HaveCount(1); + return true; + }) + .AssertPassed(); + } + + [Scenario("WithExecutionHistory null store on untyped builder throws ArgumentNullException"), Fact] + public async Task UntypedNullStore_Throws() + { + Exception? caught = null; + try + { + Workflow.Create("null-store") + .Step(new NoopStep("step1")) + .WithExecutionHistory(null!); + } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("null store passed to untyped builder", () => caught) + .Then("ArgumentNullException is thrown with paramName store", ex => + { + ex.Should().NotBeNull(); + ((ArgumentNullException)ex!).ParamName.Should().Be("store"); + return true; + }) + .AssertPassed(); + } + + // ── typed builder ───────────────────────────────────────────────────── + + [Scenario("WithExecutionHistory(store) adds history tracking to typed workflow"), Fact] + public async Task TypedWithStore_RecordsHistory() + { + var store = new InMemoryExecutionHistoryStore(); + var wf = Workflow.Create("history-typed") + .Step("step1", ctx => { ctx.Data.Value = 42; return Task.CompletedTask; }) + .WithExecutionHistory(store) + .Build(); + + var result = await wf.ExecuteAsync(new WorkflowContext(new HistData())); + + await Given("execution history store after running a typed workflow", () => (store, result)) + .Then("one record was persisted and result is successful", t => + { + t.store.AllRecords.Should().HaveCount(1); + t.store.AllRecords[0].RunId.Should().NotBeNullOrEmpty(); + t.result.IsSuccess.Should().BeTrue(); + return true; + }) + .AssertPassed(); + } + + [Scenario("WithExecutionHistory null store on typed builder throws ArgumentNullException"), Fact] + public async Task TypedNullStore_Throws() + { + Exception? caught = null; + try + { + Workflow.Create("null-typed") + .Step("step1", _ => Task.CompletedTask) + .WithExecutionHistory((IExecutionHistoryStore)null!); + } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("null store passed to typed builder", () => caught) + .Then("ArgumentNullException is thrown with paramName store", ex => + { + ex.Should().NotBeNull(); + ((ArgumentNullException)ex!).ParamName.Should().Be("store"); + return true; + }) + .AssertPassed(); + } +} + +file sealed class HistData { public int Value { get; set; } } diff --git a/tests/WorkflowFramework.Extensions.Polly.Tests/packages.lock.json b/tests/WorkflowFramework.Extensions.Polly.Tests/packages.lock.json index 884ca76..155f9e7 100644 --- a/tests/WorkflowFramework.Extensions.Polly.Tests/packages.lock.json +++ b/tests/WorkflowFramework.Extensions.Polly.Tests/packages.lock.json @@ -208,12 +208,14 @@ "type": "Project", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )", - "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )" + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", + "PatternKit.Core": "[0.105.0, )" } }, "workflowframework.extensions.polly": { "type": "Project", "dependencies": { + "PatternKit.Core": "[0.105.0, )", "Polly.Core": "[8.6.0, )", "WorkflowFramework": "[1.0.0, )" } @@ -264,6 +266,12 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", "Microsoft.Extensions.Primitives": "10.0.5" } + }, + "PatternKit.Core": { + "type": "CentralTransitive", + "requested": "[0.105.0, )", + "resolved": "0.105.0", + "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==" } }, "net8.0": { @@ -474,12 +482,14 @@ "type": "Project", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )", - "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )" + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", + "PatternKit.Core": "[0.105.0, )" } }, "workflowframework.extensions.polly": { "type": "Project", "dependencies": { + "PatternKit.Core": "[0.105.0, )", "Polly.Core": "[8.6.0, )", "WorkflowFramework": "[1.0.0, )" } @@ -532,6 +542,12 @@ "Microsoft.Extensions.Primitives": "10.0.5" } }, + "PatternKit.Core": { + "type": "CentralTransitive", + "requested": "[0.105.0, )", + "resolved": "0.105.0", + "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==" + }, "System.Diagnostics.DiagnosticSource": { "type": "CentralTransitive", "requested": "[10.0.5, )", @@ -747,12 +763,14 @@ "type": "Project", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )", - "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )" + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", + "PatternKit.Core": "[0.105.0, )" } }, "workflowframework.extensions.polly": { "type": "Project", "dependencies": { + "PatternKit.Core": "[0.105.0, )", "Polly.Core": "[8.6.0, )", "WorkflowFramework": "[1.0.0, )" } @@ -805,6 +823,12 @@ "Microsoft.Extensions.Primitives": "10.0.5" } }, + "PatternKit.Core": { + "type": "CentralTransitive", + "requested": "[0.105.0, )", + "resolved": "0.105.0", + "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==" + }, "System.Diagnostics.DiagnosticSource": { "type": "CentralTransitive", "requested": "[10.0.5, )", diff --git a/tests/WorkflowFramework.Tests.Integration/packages.lock.json b/tests/WorkflowFramework.Tests.Integration/packages.lock.json index a57eaa8..84074fb 100644 --- a/tests/WorkflowFramework.Tests.Integration/packages.lock.json +++ b/tests/WorkflowFramework.Tests.Integration/packages.lock.json @@ -168,7 +168,8 @@ "type": "Project", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )", - "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )" + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", + "PatternKit.Core": "[0.105.0, )" } }, "workflowframework.extensions.dependencyinjection": { @@ -200,6 +201,7 @@ "workflowframework.extensions.polly": { "type": "Project", "dependencies": { + "PatternKit.Core": "[0.105.0, )", "Polly.Core": "[8.6.0, )", "WorkflowFramework": "[1.0.0, )" } @@ -248,6 +250,12 @@ "Microsoft.Extensions.Primitives": "10.0.5" } }, + "PatternKit.Core": { + "type": "CentralTransitive", + "requested": "[0.105.0, )", + "resolved": "0.105.0", + "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==" + }, "Polly.Core": { "type": "CentralTransitive", "requested": "[8.6.0, )", @@ -423,7 +431,8 @@ "type": "Project", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )", - "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )" + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", + "PatternKit.Core": "[0.105.0, )" } }, "workflowframework.extensions.dependencyinjection": { @@ -456,6 +465,7 @@ "workflowframework.extensions.polly": { "type": "Project", "dependencies": { + "PatternKit.Core": "[0.105.0, )", "Polly.Core": "[8.6.0, )", "WorkflowFramework": "[1.0.0, )" } @@ -505,6 +515,12 @@ "Microsoft.Extensions.Primitives": "10.0.5" } }, + "PatternKit.Core": { + "type": "CentralTransitive", + "requested": "[0.105.0, )", + "resolved": "0.105.0", + "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==" + }, "Polly.Core": { "type": "CentralTransitive", "requested": "[8.6.0, )", @@ -686,7 +702,8 @@ "type": "Project", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )", - "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )" + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", + "PatternKit.Core": "[0.105.0, )" } }, "workflowframework.extensions.dependencyinjection": { @@ -719,6 +736,7 @@ "workflowframework.extensions.polly": { "type": "Project", "dependencies": { + "PatternKit.Core": "[0.105.0, )", "Polly.Core": "[8.6.0, )", "WorkflowFramework": "[1.0.0, )" } @@ -768,6 +786,12 @@ "Microsoft.Extensions.Primitives": "10.0.5" } }, + "PatternKit.Core": { + "type": "CentralTransitive", + "requested": "[0.105.0, )", + "resolved": "0.105.0", + "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==" + }, "Polly.Core": { "type": "CentralTransitive", "requested": "[8.6.0, )", diff --git a/tests/WorkflowFramework.Tests.TinyBDD/Agents/DslEmitterStepScenarios.cs b/tests/WorkflowFramework.Tests.TinyBDD/Agents/DslEmitterStepScenarios.cs new file mode 100644 index 0000000..d2091b3 --- /dev/null +++ b/tests/WorkflowFramework.Tests.TinyBDD/Agents/DslEmitterStepScenarios.cs @@ -0,0 +1,257 @@ +using FluentAssertions; +using NSubstitute; +using TinyBDD; +using TinyBDD.Xunit; +using WorkflowFramework.Extensions.Agents; +using WorkflowFramework.Extensions.AI; +using WorkflowFramework.Tests.TinyBDD.Support; +using Xunit; +using Xunit.Abstractions; + +namespace WorkflowFramework.Tests.TinyBDD.Agents; + +[Feature("DslEmitterStep — AI-driven DSL workflow step emission")] +public class DslEmitterStepScenarios : TinyBddTestBase +{ + public DslEmitterStepScenarios(ITestOutputHelper output) : base(output) { } + + // ── helpers ────────────────────────────────────────────────────────── + + private static IAgentProvider MakeProvider(string content) + { + var p = Substitute.For(); + p.Name.Returns("mock"); + p.CompleteAsync(Arg.Any(), Arg.Any()) + .Returns(new LlmResponse { Content = content, ToolCalls = new List() }); + return p; + } + + // ── constructor guards ──────────────────────────────────────────────── + + [Scenario("Null provider throws ArgumentNullException"), Fact] + public async Task NullProvider_Throws() + { + Exception? caught = null; + try { _ = new DslEmitterStep(null!, new DslEmitterOptions()); } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("constructing DslEmitterStep with null provider", () => caught) + .Then("ArgumentNullException is thrown", ex => + { + ex.Should().NotBeNull(); + ((ArgumentNullException)ex!).ParamName.Should().Be("provider"); + return true; + }) + .AssertPassed(); + } + + [Scenario("Null options throws ArgumentNullException"), Fact] + public async Task NullOptions_Throws() + { + var provider = MakeProvider("[]"); + Exception? caught = null; + try { _ = new DslEmitterStep(provider, null!); } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("constructing DslEmitterStep with null options", () => caught) + .Then("ArgumentNullException is thrown with paramName options", ex => + { + ex.Should().NotBeNull(); + ((ArgumentNullException)ex!).ParamName.Should().Be("options"); + return true; + }) + .AssertPassed(); + } + + // ── Name property ───────────────────────────────────────────────────── + + [Scenario("Step name defaults to 'DslEmitter' when StepName is null"), Fact] + public async Task Name_DefaultsWhenStepNameNull() + { + var step = new DslEmitterStep(MakeProvider("[]"), new DslEmitterOptions()); + + await Given("a DslEmitterStep with default options", () => step) + .Then("the step name is 'DslEmitter'", s => + { + s.Name.Should().Be("DslEmitter"); + return true; + }) + .AssertPassed(); + } + + [Scenario("Step name uses StepName when set"), Fact] + public async Task Name_UsesStepNameWhenSet() + { + var step = new DslEmitterStep(MakeProvider("[]"), new DslEmitterOptions { StepName = "MyEmitter" }); + + await Given("a DslEmitterStep with custom StepName", () => step) + .Then("the step name matches StepName", s => + { + s.Name.Should().Be("MyEmitter"); + return true; + }) + .AssertPassed(); + } + + // ── happy-path execution ────────────────────────────────────────────── + + [Scenario("Valid JSON array response is stored in EmittedSteps property"), Fact] + public async Task ValidJsonArrayResponse_StoredAsEmittedSteps() + { + var jsonResponse = "[{\"step\":\"build\",\"cmd\":\"dotnet build\"},{\"step\":\"test\"}]"; + var step = new DslEmitterStep(MakeProvider(jsonResponse), new DslEmitterOptions { MaxIterations = 1 }); + var context = new WorkflowContext(); + + await step.ExecuteAsync(context); + + await Given("context after executing DslEmitterStep with valid JSON array response", () => context) + .Then("EmittedSteps property contains two items", ctx => + { + var key = step.Name + ".EmittedSteps"; + ctx.Properties.Should().ContainKey(key); + ((List)ctx.Properties[key]!).Should().HaveCount(2); + return true; + }) + .AssertPassed(); + } + + [Scenario("Task property in context is used as user prompt"), Fact] + public async Task TaskProperty_UsedAsUserPrompt() + { + LlmRequest? capturedRequest = null; + var provider = Substitute.For(); + provider.Name.Returns("mock"); + provider.CompleteAsync(Arg.Any(), Arg.Any()) + .Returns(ci => + { + capturedRequest = ci.Arg(); + return Task.FromResult(new LlmResponse { Content = "[]", ToolCalls = new List() }); + }); + + var step = new DslEmitterStep(provider, new DslEmitterOptions { MaxIterations = 1 }); + var context = new WorkflowContext(); + context.Properties["task"] = "generate CI/CD pipeline"; + + await step.ExecuteAsync(context); + + await Given("context with task property", () => capturedRequest) + .Then("the task text was included in the request prompt", req => + { + req!.Prompt.Should().Contain("generate CI/CD pipeline"); + return true; + }) + .AssertPassed(); + } + + [Scenario("Iterations count is stored after execution"), Fact] + public async Task IterationsCount_Stored() + { + var step = new DslEmitterStep(MakeProvider("[{}]"), new DslEmitterOptions { MaxIterations = 3 }); + var context = new WorkflowContext(); + + await step.ExecuteAsync(context); + + await Given("context after executing DslEmitterStep", () => context) + .Then("Iterations property is set and at least 1", ctx => + { + var key = step.Name + ".Iterations"; + ctx.Properties.Should().ContainKey(key); + ((int)ctx.Properties[key]!).Should().BeGreaterThanOrEqualTo(1); + return true; + }) + .AssertPassed(); + } + + // ── retry behavior ──────────────────────────────────────────────────── + + [Scenario("Non-JSON response causes retry up to MaxIterations"), Fact] + public async Task NonJsonResponse_CausesRetryUpToMaxIterations() + { + // Provider always returns prose — no valid JSON array + var step = new DslEmitterStep(MakeProvider("Here are the steps you need to follow:"), + new DslEmitterOptions { MaxIterations = 2 }); + var context = new WorkflowContext(); + + await step.ExecuteAsync(context); + + await Given("context after DslEmitter with non-parseable response", () => context) + .Then("EmittedSteps is empty and Iterations equals MaxIterations", ctx => + { + var emittedKey = step.Name + ".EmittedSteps"; + var iterKey = step.Name + ".Iterations"; + ((List)ctx.Properties[emittedKey]!).Should().BeEmpty(); + ((int)ctx.Properties[iterKey]!).Should().Be(2); + return true; + }) + .AssertPassed(); + } + + // ── cancellation ────────────────────────────────────────────────────── + + [Scenario("Cancellation before first LLM call aborts cleanly"), Fact] + public async Task CancellationDuringLlmCall_AbortsCleanly() + { + var provider = Substitute.For(); + provider.Name.Returns("mock"); + provider.CompleteAsync(Arg.Any(), Arg.Any()) + .Returns>(ci => + { + ci.Arg().ThrowIfCancellationRequested(); + return Task.FromResult(new LlmResponse { Content = "[]", ToolCalls = new List() }); + }); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var step = new DslEmitterStep(provider, new DslEmitterOptions { MaxIterations = 3 }); + var context = new WorkflowContext(cts.Token); + + // Should not throw — OperationCanceledException is caught internally + await step.ExecuteAsync(context); + + await Given("context after cancelled DslEmitterStep execution", () => context) + .Then("EmittedSteps is empty", ctx => + { + ((List)ctx.Properties[step.Name + ".EmittedSteps"]!).Should().BeEmpty(); + return true; + }) + .AssertPassed(); + } + + // ── DslEmitterOptions ──────────────────────────────────────────────── + + [Scenario("DslEmitterOptions MaxIterations defaults to 3"), Fact] + public async Task DslEmitterOptions_MaxIterationsDefaults() + { + var options = new DslEmitterOptions(); + + await Given("default DslEmitterOptions", () => options) + .Then("MaxIterations is 3 and StepName is null", o => + { + o.MaxIterations.Should().Be(3); + o.StepName.Should().BeNull(); + return true; + }) + .AssertPassed(); + } + + [Scenario("JSON array embedded in prose is extracted"), Fact] + public async Task JsonArrayEmbeddedInProse_IsExtracted() + { + // Response has prose before and after the array + var response = "Sure! Here is the plan: [{\"step\":\"build\"}] Hope that helps!"; + var step = new DslEmitterStep(MakeProvider(response), new DslEmitterOptions { MaxIterations = 1 }); + var context = new WorkflowContext(); + + await step.ExecuteAsync(context); + + await Given("context after executing with array embedded in prose", () => context) + .Then("EmittedSteps has one item extracted", ctx => + { + var emitted = (List)ctx.Properties[step.Name + ".EmittedSteps"]!; + emitted.Should().HaveCount(1); + return true; + }) + .AssertPassed(); + } +} diff --git a/tests/WorkflowFramework.Tests.TinyBDD/Integration/Composition/ScatterGatherStepScenarios.cs b/tests/WorkflowFramework.Tests.TinyBDD/Integration/Composition/ScatterGatherStepScenarios.cs index 5dc5e3a..cc8428e 100644 --- a/tests/WorkflowFramework.Tests.TinyBDD/Integration/Composition/ScatterGatherStepScenarios.cs +++ b/tests/WorkflowFramework.Tests.TinyBDD/Integration/Composition/ScatterGatherStepScenarios.cs @@ -183,4 +183,72 @@ await Given("results received by aggregator with empty handler list", () => rece }) .AssertPassed(); } + + [Scenario("Handler that throws OperationCanceledException is swallowed and returns null"), Fact] + public async Task HandlerOperationCanceledException_IsSwallowed() + { + IReadOnlyList? received = null; + + var cancelling = Substitute.For(); + cancelling.Name.Returns("cancelling"); + cancelling.ExecuteAsync(Arg.Any()) + .Returns(_ => Task.FromException(new OperationCanceledException())); + + var sut = new ScatterGatherStep( + new[] { cancelling }, + (results, _) => { received = results; return Task.CompletedTask; }, + TimeSpan.FromSeconds(5)); + + await sut.ExecuteAsync(new WorkflowContext()); + + await Given("results after handler throws OperationCanceledException", () => received) + .Then("aggregator receives null result for cancelled handler", r => + { + r.Should().NotBeNull().And.ContainSingle(v => v == null); + return true; + }) + .AssertPassed(); + } + + [Scenario("Timeout fires and aggregator receives partial results"), Fact] + public async Task Timeout_AggregatorsReceivesPartialResults() + { + IReadOnlyList? received = null; + + var fast = Substitute.For(); + fast.Name.Returns("fast"); + fast.ExecuteAsync(Arg.Any()) + .Returns(ci => + { + ((IWorkflowContext)ci[0]).Properties["__Result_fast"] = "done"; + return Task.CompletedTask; + }); + + var slow = Substitute.For(); + slow.Name.Returns("slow"); + slow.ExecuteAsync(Arg.Any()) + .Returns(async _ => + { + // Delay longer than the ScatterGatherStep timeout (50ms) but bounded + // so the task eventually completes and doesn't orphan the testhost. + await Task.Delay(500).ConfigureAwait(false); + }); + + // Very short timeout to trigger partial results path. + var sut = new ScatterGatherStep( + new[] { fast, slow }, + (results, _) => { received = results; return Task.CompletedTask; }, + TimeSpan.FromMilliseconds(50)); + + var ctx = new WorkflowContext(); + await sut.ExecuteAsync(ctx); + + await Given("partial results after scatter-gather timeout", () => received) + .Then("aggregator received partial results (not null, from completed handlers)", r => + { + r.Should().NotBeNull(); + return true; + }) + .AssertPassed(); + } } diff --git a/tests/WorkflowFramework.Tests.TinyBDD/Serialization/SerializationScenarios.cs b/tests/WorkflowFramework.Tests.TinyBDD/Serialization/SerializationScenarios.cs new file mode 100644 index 0000000..8753eb7 --- /dev/null +++ b/tests/WorkflowFramework.Tests.TinyBDD/Serialization/SerializationScenarios.cs @@ -0,0 +1,712 @@ +using FluentAssertions; +using TinyBDD; +using TinyBDD.Xunit; +using WorkflowFramework.Builder; +using WorkflowFramework.Internal; +using WorkflowFramework.Serialization; +using WorkflowFramework.Tests.TinyBDD.Support; +using Xunit; +using Xunit.Abstractions; + +namespace WorkflowFramework.Tests.TinyBDD.Serialization; + +[Feature("WorkflowSerializer — round-trip, canvas DTO, YAML writer, StepInspector")] +public class SerializationScenarios : TinyBddTestBase +{ + public SerializationScenarios(ITestOutputHelper output) : base(output) { } + + // ── helpers ────────────────────────────────────────────────────────── + + private sealed class SimpleStep(string name) : IStep + { + public string Name => name; + public Task ExecuteAsync(IWorkflowContext context) => Task.CompletedTask; + } + + // ── WorkflowCanvasDto ──────────────────────────────────────────────── + + [Scenario("WorkflowCanvasDto default construction has empty collections"), Fact] + public async Task CanvasDto_DefaultHasEmptyCollections() + { + var dto = new WorkflowCanvasDto(); + + await Given("a default WorkflowCanvasDto", () => dto) + .Then("Nodes and Edges are empty", d => + { + d.Nodes.Should().NotBeNull().And.BeEmpty(); + d.Edges.Should().NotBeNull().And.BeEmpty(); + return true; + }) + .AssertPassed(); + } + + [Scenario("WorkflowCanvasNodeDto round-trips through JSON with all properties"), Fact] + public async Task CanvasNodeDto_RoundTripsAllProperties() + { + var node = new WorkflowCanvasNodeDto + { + Id = "n1", + Type = "action", + Label = "Build", + Icon = "hammer", + Category = "CI", + Color = "#ff0000", + X = 100.5, + Y = 200.75, + Config = new Dictionary { ["cmd"] = "dotnet build" } + }; + + var dto = new WorkflowDefinitionDto + { + Name = "canvas-test", + Canvas = new WorkflowCanvasDto { Nodes = { node } } + }; + + // Serialize/deserialize the DTO directly via JSON round-trip + var json = System.Text.Json.JsonSerializer.Serialize(dto, + new System.Text.Json.JsonSerializerOptions + { + WriteIndented = false, + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase + }); + var parsed = System.Text.Json.JsonSerializer.Deserialize(json, + new System.Text.Json.JsonSerializerOptions + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase + }); + + await Given("a WorkflowDefinitionDto with canvas node", () => parsed) + .Then("the canvas node round-trips correctly", p => + { + p.Should().NotBeNull(); + p!.Canvas.Should().NotBeNull(); + p.Canvas!.Nodes.Should().HaveCount(1); + var n = p.Canvas.Nodes[0]; + n.Id.Should().Be("n1"); + n.Type.Should().Be("action"); + n.Label.Should().Be("Build"); + n.Icon.Should().Be("hammer"); + n.Category.Should().Be("CI"); + n.Color.Should().Be("#ff0000"); + n.X.Should().Be(100.5); + n.Y.Should().Be(200.75); + n.Config.Should().ContainKey("cmd").WhoseValue.Should().Be("dotnet build"); + return true; + }) + .AssertPassed(); + } + + [Scenario("WorkflowCanvasEdgeDto round-trips through JSON with all properties"), Fact] + public async Task CanvasEdgeDto_RoundTripsAllProperties() + { + var edge = new WorkflowCanvasEdgeDto + { + Id = "e1", + Kind = "step", + Source = "n1", + Target = "n2", + Label = "success" + }; + + var dto = new WorkflowDefinitionDto + { + Name = "edge-test", + Canvas = new WorkflowCanvasDto { Edges = { edge } } + }; + + var json = System.Text.Json.JsonSerializer.Serialize(dto, + new System.Text.Json.JsonSerializerOptions + { + WriteIndented = false, + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase + }); + var parsed = System.Text.Json.JsonSerializer.Deserialize(json, + new System.Text.Json.JsonSerializerOptions + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase + }); + + await Given("a WorkflowDefinitionDto with canvas edge", () => parsed) + .Then("the canvas edge round-trips correctly", p => + { + p!.Canvas!.Edges.Should().HaveCount(1); + var e = p.Canvas.Edges[0]; + e.Id.Should().Be("e1"); + e.Kind.Should().Be("step"); + e.Source.Should().Be("n1"); + e.Target.Should().Be("n2"); + e.Label.Should().Be("success"); + return true; + }) + .AssertPassed(); + } + + [Scenario("WorkflowCanvasNodeDto optional fields are null by default"), Fact] + public async Task CanvasNodeDto_OptionalFieldsNullByDefault() + { + var node = new WorkflowCanvasNodeDto { Id = "x", Type = "t", Label = "l", X = 0, Y = 0 }; + + await Given("a minimal WorkflowCanvasNodeDto", () => node) + .Then("optional fields are null", n => + { + n.Icon.Should().BeNull(); + n.Category.Should().BeNull(); + n.Color.Should().BeNull(); + n.Config.Should().BeNull(); + return true; + }) + .AssertPassed(); + } + + [Scenario("WorkflowCanvasEdgeDto optional fields are null by default"), Fact] + public async Task CanvasEdgeDto_OptionalFieldsNullByDefault() + { + var edge = new WorkflowCanvasEdgeDto { Source = "a", Target = "b" }; + + await Given("a minimal WorkflowCanvasEdgeDto", () => edge) + .Then("optional Id, Kind, and Label are null", e => + { + e.Id.Should().BeNull(); + e.Kind.Should().BeNull(); + e.Label.Should().BeNull(); + return true; + }) + .AssertPassed(); + } + + // ── YamlWriter ──────────────────────────────────────────────────────── + + [Scenario("YamlWriter emits maxAttempts for retry step"), Fact] + public async Task YamlWriter_EmitsMaxAttempts() + { + var dto = new WorkflowDefinitionDto + { + Name = "retry-wf", + Steps = { new StepDefinitionDto { Name = "retry-step", Type = "retry", MaxAttempts = 5 } } + }; + + var yaml = YamlWriter.Write(dto); + + await Given("a workflow DTO with retry step", () => yaml) + .Then("YAML contains maxAttempts: 5", y => + { + y.Should().Contain("maxAttempts: 5"); + return true; + }) + .AssertPassed(); + } + + [Scenario("YamlWriter emits timeoutSeconds for timeout step"), Fact] + public async Task YamlWriter_EmitsTimeoutSeconds() + { + var dto = new WorkflowDefinitionDto + { + Name = "timeout-wf", + Steps = + { + new StepDefinitionDto + { + Name = "ts", Type = "timeout", TimeoutSeconds = 30.5, + Inner = new StepDefinitionDto { Name = "inner", Type = "action" } + } + } + }; + + var yaml = YamlWriter.Write(dto); + + await Given("a workflow DTO with timeout step", () => yaml) + .Then("YAML contains timeoutSeconds: 30.5", y => + { + y.Should().Contain("timeoutSeconds: 30.5"); + return true; + }) + .AssertPassed(); + } + + [Scenario("YamlWriter emits delaySeconds for delay step"), Fact] + public async Task YamlWriter_EmitsDelaySeconds() + { + var dto = new WorkflowDefinitionDto + { + Name = "delay-wf", + Steps = { new StepDefinitionDto { Name = "ds", Type = "delay", DelaySeconds = 2.5 } } + }; + + var yaml = YamlWriter.Write(dto); + + await Given("a workflow DTO with delay step", () => yaml) + .Then("YAML contains delaySeconds: 2.5", y => + { + y.Should().Contain("delaySeconds: 2.5"); + return true; + }) + .AssertPassed(); + } + + [Scenario("YamlWriter emits subWorkflowName for subWorkflow step"), Fact] + public async Task YamlWriter_EmitsSubWorkflowName() + { + var dto = new WorkflowDefinitionDto + { + Name = "sub-wf", + Steps = { new StepDefinitionDto { Name = "sw", Type = "subWorkflow", SubWorkflowName = "other-workflow" } } + }; + + var yaml = YamlWriter.Write(dto); + + await Given("a workflow DTO with subWorkflow step", () => yaml) + .Then("YAML contains subWorkflowName: other-workflow", y => + { + y.Should().Contain("subWorkflowName: other-workflow"); + return true; + }) + .AssertPassed(); + } + + [Scenario("YamlWriter emits then and else branches for conditional step"), Fact] + public async Task YamlWriter_EmitsThenElseBranches() + { + var dto = new WorkflowDefinitionDto + { + Name = "cond-wf", + Steps = + { + new StepDefinitionDto + { + Name = "cond", Type = "conditional", + Then = new StepDefinitionDto { Name = "then-step", Type = "action" }, + Else = new StepDefinitionDto { Name = "else-step", Type = "action" } + } + } + }; + + var yaml = YamlWriter.Write(dto); + + await Given("a workflow DTO with conditional step", () => yaml) + .Then("YAML contains then and else branches", y => + { + y.Should().Contain("then:"); + y.Should().Contain("then-step"); + y.Should().Contain("else:"); + y.Should().Contain("else-step"); + return true; + }) + .AssertPassed(); + } + + [Scenario("YamlWriter emits tryBody, catchTypes, and finallyBody for tryCatch step"), Fact] + public async Task YamlWriter_EmitsTryCatchFields() + { + var dto = new WorkflowDefinitionDto + { + Name = "trycatch-wf", + Steps = + { + new StepDefinitionDto + { + Name = "tc", Type = "tryCatch", + TryBody = new List { new() { Name = "try-step", Type = "action" } }, + CatchTypes = new List { "System.Exception" }, + FinallyBody = new List { new() { Name = "finally-step", Type = "action" } } + } + } + }; + + var yaml = YamlWriter.Write(dto); + + await Given("a workflow DTO with tryCatch step", () => yaml) + .Then("YAML contains tryBody, catchTypes, and finallyBody", y => + { + y.Should().Contain("tryBody:"); + y.Should().Contain("try-step"); + y.Should().Contain("catchTypes:"); + y.Should().Contain("System.Exception"); + y.Should().Contain("finallyBody:"); + y.Should().Contain("finally-step"); + return true; + }) + .AssertPassed(); + } + + [Scenario("YamlWriter escapes special characters in names"), Fact] + public async Task YamlWriter_EscapesSpecialCharacters() + { + var dto = new WorkflowDefinitionDto + { + Name = "name: with colon", + Steps = { new StepDefinitionDto { Name = "step #1", Type = "action" } } + }; + + var yaml = YamlWriter.Write(dto); + + await Given("a workflow DTO with special characters", () => yaml) + .Then("YAML wraps special names in quotes", y => + { + y.Should().Contain("\"name: with colon\""); + y.Should().Contain("\"step #1\""); + return true; + }) + .AssertPassed(); + } + + [Scenario("YamlWriter emits child steps for parallel step"), Fact] + public async Task YamlWriter_EmitsParallelSteps() + { + var dto = new WorkflowDefinitionDto + { + Name = "parallel-wf", + Steps = + { + new StepDefinitionDto + { + Name = "par", Type = "parallel", + Steps = new List + { + new() { Name = "branch-a", Type = "action" }, + new() { Name = "branch-b", Type = "action" } + } + } + } + }; + + var yaml = YamlWriter.Write(dto); + + await Given("a workflow DTO with parallel step and two branches", () => yaml) + .Then("YAML contains steps section with branch-a and branch-b", y => + { + y.Should().Contain("steps:"); + y.Should().Contain("branch-a"); + y.Should().Contain("branch-b"); + return true; + }) + .AssertPassed(); + } + + [Scenario("YamlWriter emits inner step for timeout"), Fact] + public async Task YamlWriter_EmitsInnerStep() + { + var dto = new WorkflowDefinitionDto + { + Name = "inner-wf", + Steps = + { + new StepDefinitionDto + { + Name = "wrapper", Type = "timeout", TimeoutSeconds = 5, + Inner = new StepDefinitionDto { Name = "wrapped-action", Type = "action" } + } + } + }; + + var yaml = YamlWriter.Write(dto); + + await Given("a workflow DTO with inner step", () => yaml) + .Then("YAML contains inner section and the wrapped step", y => + { + y.Should().Contain("inner:"); + y.Should().Contain("wrapped-action"); + return true; + }) + .AssertPassed(); + } + + // ── StepInspector ──────────────────────────────────────────────────── + + [Scenario("ToDefinition inspects a simple action step"), Fact] + public async Task ToDefinition_SimpleActionStep() + { + var wf = Workflow.Create("wf") + .Step(new SimpleStep("build")) + .Build(); + + var dto = WorkflowSerializer.ToDefinition(wf); + + await Given("a workflow with one simple step", () => dto) + .Then("step is mapped to DTO with correct name and type", d => + { + d.Steps.Should().HaveCount(1); + d.Steps[0].Name.Should().Be("build"); + return true; + }) + .AssertPassed(); + } + + [Scenario("ToDefinition inspects a conditional step structure"), Fact] + public async Task ToDefinition_ConditionalStep() + { + var wf = Workflow.Create("cond-wf") + .If(_ => true) + .Then(new SimpleStep("then-step")) + .EndIf() + .Build(); + + var dto = WorkflowSerializer.ToDefinition(wf); + + await Given("a workflow with conditional step", () => dto) + .Then("step type is conditional with then branch", d => + { + d.Steps.Should().HaveCount(1); + d.Steps[0].Type.Should().Be("conditional"); + d.Steps[0].Then.Should().NotBeNull(); + return true; + }) + .AssertPassed(); + } + + [Scenario("ToDefinition inspects parallel step"), Fact] + public async Task ToDefinition_ParallelStep() + { + var wf = Workflow.Create("par-wf") + .Parallel(b => b + .Step(new SimpleStep("branch-a")) + .Step(new SimpleStep("branch-b"))) + .Build(); + + var dto = WorkflowSerializer.ToDefinition(wf); + + await Given("a workflow with parallel step", () => dto) + .Then("step type is parallel with child steps", d => + { + d.Steps.Should().HaveCount(1); + d.Steps[0].Type.Should().Be("parallel"); + d.Steps[0].Steps.Should().HaveCount(2); + return true; + }) + .AssertPassed(); + } + + [Scenario("ToDefinition handles a delay step"), Fact] + public async Task ToDefinition_DelayStep() + { + var wf = Workflow.Create("delay-wf") + .Delay(TimeSpan.FromSeconds(2)) + .Build(); + + var dto = WorkflowSerializer.ToDefinition(wf); + + await Given("a workflow with a delay step", () => dto) + .Then("step type is delay with delaySeconds set", d => + { + d.Steps.Should().HaveCount(1); + d.Steps[0].Type.Should().Be("delay"); + d.Steps[0].DelaySeconds.Should().Be(2); + return true; + }) + .AssertPassed(); + } + + [Scenario("ToDefinition handles while loop step"), Fact] + public async Task ToDefinition_WhileLoopStep() + { + var count = 0; + var wf = Workflow.Create("while-wf") + .While(_ => count++ < 1, b => b.Step(new SimpleStep("loop-body"))) + .Build(); + + var dto = WorkflowSerializer.ToDefinition(wf); + + await Given("a workflow with while loop step", () => dto) + .Then("step type is while with body steps", d => + { + d.Steps.Should().HaveCount(1); + d.Steps[0].Type.Should().Be("while"); + return true; + }) + .AssertPassed(); + } + + [Scenario("ToDefinition inspects a retry step"), Fact] + public async Task ToDefinition_RetryStep() + { + var wf = Workflow.Create("retry-wf") + .Retry(b => b.Step(new SimpleStep("retried")), maxAttempts: 3) + .Build(); + + var dto = WorkflowSerializer.ToDefinition(wf); + + await Given("a workflow with retry step", () => dto) + .Then("step type is retry with maxAttempts populated", d => + { + d.Steps.Should().HaveCount(1); + d.Steps[0].Type.Should().Be("retry"); + d.Steps[0].MaxAttempts.Should().Be(3); + return true; + }) + .AssertPassed(); + } + + [Scenario("StepDefinitionDto default values are correct"), Fact] + public async Task StepDefinitionDto_DefaultValues() + { + var dto = new StepDefinitionDto(); + + await Given("a default StepDefinitionDto", () => dto) + .Then("numeric fields default to zero and string to empty", d => + { + d.MaxAttempts.Should().Be(0); + d.TimeoutSeconds.Should().Be(0); + d.DelaySeconds.Should().Be(0); + d.Name.Should().Be(string.Empty); + d.Type.Should().Be(string.Empty); + return true; + }) + .AssertPassed(); + } + + [Scenario("ToDefinition inspects a DoWhile step"), Fact] + public async Task ToDefinition_DoWhileStep() + { + var ran = false; + var wf = Workflow.Create("dowhile-wf") + .DoWhile(b => b.Step(new SimpleStep("body-step")), _ => { ran = true; return false; }) + .Build(); + + var dto = WorkflowSerializer.ToDefinition(wf); + + await Given("a workflow with DoWhile step", () => dto) + .Then("step type is doWhile", d => + { + d.Steps.Should().HaveCount(1); + d.Steps[0].Type.Should().Be("doWhile"); + return true; + }) + .AssertPassed(); + } + + [Scenario("ToDefinition inspects a ForEach step"), Fact] + public async Task ToDefinition_ForEachStep() + { + var wf = Workflow.Create("foreach-wf") + .ForEach( + _ => new[] { 1, 2, 3 }, + b => b.Step(new SimpleStep("item-step"))) + .Build(); + + var dto = WorkflowSerializer.ToDefinition(wf); + + await Given("a workflow with ForEach step", () => dto) + .Then("step type is forEach", d => + { + d.Steps.Should().HaveCount(1); + d.Steps[0].Type.Should().Be("forEach"); + return true; + }) + .AssertPassed(); + } + + [Scenario("ToDefinition inspects a Timeout step wrapping an inner step"), Fact] + public async Task ToDefinition_TimeoutStep() + { + // TimeoutStep is internal — construct directly using InternalsVisibleTo + var inner = new SimpleStep("guarded"); + var timeoutStep = new TimeoutStep(inner, TimeSpan.FromSeconds(10)); + var wf = Workflow.Create("timeout-wf") + .Step(timeoutStep) + .Build(); + + var dto = WorkflowSerializer.ToDefinition(wf); + + await Given("a workflow with Timeout step", () => dto) + .Then("step type is timeout and timeoutSeconds is set", d => + { + d.Steps.Should().HaveCount(1); + d.Steps[0].Type.Should().Be("timeout"); + d.Steps[0].TimeoutSeconds.Should().Be(10); + d.Steps[0].Inner.Should().NotBeNull(); + return true; + }) + .AssertPassed(); + } + + [Scenario("ToDefinition inspects a TryCatch step"), Fact] + public async Task ToDefinition_TryCatchStep() + { + var wf = Workflow.Create("try-wf") + .Try(b => b.Step(new SimpleStep("risky"))) + .Catch((_, _) => Task.CompletedTask) + .EndTry() + .Build(); + + var dto = WorkflowSerializer.ToDefinition(wf); + + await Given("a workflow with TryCatch step", () => dto) + .Then("step type is tryCatch with tryBody and catchTypes", d => + { + d.Steps.Should().HaveCount(1); + d.Steps[0].Type.Should().Be("tryCatch"); + d.Steps[0].TryBody.Should().NotBeNull(); + d.Steps[0].CatchTypes.Should().NotBeNull(); + return true; + }) + .AssertPassed(); + } + + [Scenario("ToDefinition inspects a SubWorkflow step"), Fact] + public async Task ToDefinition_SubWorkflowStep() + { + var sub = Workflow.Create("inner-workflow") + .Step(new SimpleStep("inner-step")) + .Build(); + + var wf = Workflow.Create("outer-wf") + .SubWorkflow(sub) + .Build(); + + var dto = WorkflowSerializer.ToDefinition(wf); + + await Given("a workflow with SubWorkflow step", () => dto) + .Then("step type is subWorkflow and subWorkflowName matches", d => + { + d.Steps.Should().HaveCount(1); + d.Steps[0].Type.Should().Be("subWorkflow"); + d.Steps[0].SubWorkflowName.Should().Be("inner-workflow"); + return true; + }) + .AssertPassed(); + } + + [Scenario("ToDefinition maps a custom step using full type name"), Fact] + public async Task ToDefinition_CustomStep_UsesFullTypeName() + { + var wf = Workflow.Create("custom-wf") + .Step(new SimpleStep("my-custom-step")) + .Build(); + + var dto = WorkflowSerializer.ToDefinition(wf); + + await Given("a workflow with a custom step", () => dto) + .Then("step name is preserved", d => + { + d.Steps.Should().HaveCount(1); + d.Steps[0].Name.Should().Be("my-custom-step"); + return true; + }) + .AssertPassed(); + } + + [Scenario("ToDefinition marks ICompensatingStep with saga prefix"), Fact] + public async Task ToDefinition_CompensatingStep_HasSagaPrefix() + { + var wf = Workflow.Create("saga-wf") + .Step(new CompensatingTestStep("compensate-step")) + .Build(); + + var dto = WorkflowSerializer.ToDefinition(wf); + + await Given("a workflow with a compensating step", () => dto) + .Then("step type has saga: prefix", d => + { + d.Steps.Should().HaveCount(1); + d.Steps[0].Type.Should().StartWith("saga:"); + return true; + }) + .AssertPassed(); + } +} + +file sealed class CompensatingTestStep(string name) : IStep, ICompensatingStep +{ + public string Name => name; + public Task ExecuteAsync(IWorkflowContext context) => Task.CompletedTask; + public Task CompensateAsync(IWorkflowContext context) => Task.CompletedTask; +} diff --git a/tests/WorkflowFramework.Tests.TinyBDD/Testing/WorkflowTestHarnessTests.cs b/tests/WorkflowFramework.Tests.TinyBDD/Testing/WorkflowTestHarnessTests.cs index bc799e5..932fc23 100644 --- a/tests/WorkflowFramework.Tests.TinyBDD/Testing/WorkflowTestHarnessTests.cs +++ b/tests/WorkflowFramework.Tests.TinyBDD/Testing/WorkflowTestHarnessTests.cs @@ -130,6 +130,102 @@ await Given("side effects after lambda override", () => sideEffect) }) .AssertPassed(); } + + [Scenario("ExecuteAsync without overrides runs the typed workflow directly"), Fact] + public async Task TypedExecuteWithoutOverridesRunsTypedWorkflow() + { + var ran = false; + var workflow = Workflow.Create("typed-no-override") + .Step("compute", ctx => { ran = true; ctx.Data.Value = 42; return Task.CompletedTask; }) + .Build(); + + var harness = new WorkflowTestHarness(); // no overrides configured + var result = await harness.ExecuteAsync(workflow, new HarnessNumberData { Value = 0 }); + + await Given("typed result with no harness overrides", () => (result, ran)) + .Then("original step ran and data is mutated", t => + { + t.result.IsSuccess.Should().BeTrue(); + t.result.Data.Value.Should().Be(42); + t.ran.Should().BeTrue(); + return true; + }) + .AssertPassed(); + } + + [Scenario("ExecuteAsync chains OverrideStep calls via fluent builder"), Fact] + public async Task OverrideStepIsChainable() + { + var workflow = Workflow.Create("fluent") + .Step(new LambdaStep("a", _ => Task.CompletedTask)) + .Step(new LambdaStep("b", _ => Task.CompletedTask)) + .Build(); + + var fakeA = new FakeStep("a"); + var fakeB = new FakeStep("b"); + var harness = new WorkflowTestHarness() + .OverrideStep("a", fakeA) + .OverrideStep("b", fakeB); + + await harness.ExecuteAsync(workflow, new WorkflowContext()); + + await Given("harness configured with two chained overrides", () => (fakeA, fakeB)) + .Then("both fakes executed once", t => + { + t.fakeA.ExecutionCount.Should().Be(1); + t.fakeB.ExecutionCount.Should().Be(1); + return true; + }) + .AssertPassed(); + } + + [Scenario("ExecuteAsync with overrides and dual-interface workflow applies overrides"), Fact] + public async Task TypedExecuteWithOverridesAppliesWhenWorkflowImplementsIWorkflow() + { + var originalRan = false; + var dualWorkflow = new DualInterfaceWorkflow( + "dual-override", + new LambdaStep("expensive", _ => { originalRan = true; return Task.CompletedTask; })); + + var fake = new FakeStep("expensive"); + var harness = new WorkflowTestHarness(); + harness.OverrideStep("expensive", fake); + var result = await harness.ExecuteAsync(dualWorkflow, new HarnessNumberData { Value = 5 }); + + await Given("typed result after override applied via dual-interface workflow", () => (result, fake, originalRan)) + .Then("fake ran instead of original and result is successful", t => + { + t.result.IsSuccess.Should().BeTrue(); + t.fake.ExecutionCount.Should().Be(1); + t.originalRan.Should().BeFalse(); + return true; + }) + .AssertPassed(); + } } file sealed class HarnessNumberData { public int Value { get; set; } } + +/// +/// A test-only workflow that implements both IWorkflow and IWorkflow<TData> +/// so that WorkflowTestHarness can apply typed overrides via the IWorkflow path. +/// +file sealed class DualInterfaceWorkflow(string name, params IStep[] steps) + : IWorkflow, IWorkflow + where TData : class +{ + public string Name => name; + public IReadOnlyList Steps => steps; + + public Task ExecuteAsync(IWorkflowContext context) => + Workflow.Create(name) + .Step(steps[0]) + .Build() + .ExecuteAsync(context); + + public async Task> ExecuteAsync(IWorkflowContext context) + { + var result = await ExecuteAsync((IWorkflowContext)context).ConfigureAwait(false); + return new WorkflowResult(result.Status, context); + } +} diff --git a/tests/WorkflowFramework.Tests.TinyBDD/WorkflowFramework.Tests.TinyBDD.csproj b/tests/WorkflowFramework.Tests.TinyBDD/WorkflowFramework.Tests.TinyBDD.csproj index ace1775..79760f6 100644 --- a/tests/WorkflowFramework.Tests.TinyBDD/WorkflowFramework.Tests.TinyBDD.csproj +++ b/tests/WorkflowFramework.Tests.TinyBDD/WorkflowFramework.Tests.TinyBDD.csproj @@ -14,6 +14,7 @@ + diff --git a/tests/WorkflowFramework.Tests.TinyBDD/packages.lock.json b/tests/WorkflowFramework.Tests.TinyBDD/packages.lock.json index 055ab12..4ca3c55 100644 --- a/tests/WorkflowFramework.Tests.TinyBDD/packages.lock.json +++ b/tests/WorkflowFramework.Tests.TinyBDD/packages.lock.json @@ -401,6 +401,12 @@ "WorkflowFramework": "[1.0.0, )" } }, + "workflowframework.serialization": { + "type": "Project", + "dependencies": { + "WorkflowFramework": "[1.0.0, )" + } + }, "workflowframework.testing": { "type": "Project", "dependencies": { @@ -912,6 +918,12 @@ "WorkflowFramework": "[1.0.0, )" } }, + "workflowframework.serialization": { + "type": "Project", + "dependencies": { + "WorkflowFramework": "[1.0.0, )" + } + }, "workflowframework.testing": { "type": "Project", "dependencies": { @@ -1440,6 +1452,12 @@ "WorkflowFramework": "[1.0.0, )" } }, + "workflowframework.serialization": { + "type": "Project", + "dependencies": { + "WorkflowFramework": "[1.0.0, )" + } + }, "workflowframework.testing": { "type": "Project", "dependencies": { diff --git a/tests/WorkflowFramework.Tests/packages.lock.json b/tests/WorkflowFramework.Tests/packages.lock.json index 3fa4de0..1fd6f4d 100644 --- a/tests/WorkflowFramework.Tests/packages.lock.json +++ b/tests/WorkflowFramework.Tests/packages.lock.json @@ -582,7 +582,8 @@ "type": "Project", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )", - "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )" + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", + "PatternKit.Core": "[0.105.0, )" } }, "workflowframework.extensions.agents": { @@ -727,6 +728,7 @@ "workflowframework.extensions.integration": { "type": "Project", "dependencies": { + "PatternKit.Core": "[0.105.0, )", "WorkflowFramework": "[1.0.0, )", "WorkflowFramework.Extensions.Integration.Abstractions": "[1.0.0, )" } @@ -790,6 +792,7 @@ "workflowframework.extensions.polly": { "type": "Project", "dependencies": { + "PatternKit.Core": "[0.105.0, )", "Polly.Core": "[8.6.0, )", "WorkflowFramework": "[1.0.0, )" } @@ -948,6 +951,12 @@ "Npgsql": "9.0.3" } }, + "PatternKit.Core": { + "type": "CentralTransitive", + "requested": "[0.105.0, )", + "resolved": "0.105.0", + "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==" + }, "Polly.Core": { "type": "CentralTransitive", "requested": "[8.6.0, )", @@ -1592,7 +1601,8 @@ "type": "Project", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )", - "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )" + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", + "PatternKit.Core": "[0.105.0, )" } }, "workflowframework.extensions.agents": { @@ -1744,6 +1754,7 @@ "workflowframework.extensions.integration": { "type": "Project", "dependencies": { + "PatternKit.Core": "[0.105.0, )", "WorkflowFramework": "[1.0.0, )", "WorkflowFramework.Extensions.Integration.Abstractions": "[1.0.0, )" } @@ -1809,6 +1820,7 @@ "workflowframework.extensions.polly": { "type": "Project", "dependencies": { + "PatternKit.Core": "[0.105.0, )", "Polly.Core": "[8.6.0, )", "WorkflowFramework": "[1.0.0, )" } @@ -1971,6 +1983,12 @@ "Npgsql": "9.0.3" } }, + "PatternKit.Core": { + "type": "CentralTransitive", + "requested": "[0.105.0, )", + "resolved": "0.105.0", + "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==" + }, "Polly.Core": { "type": "CentralTransitive", "requested": "[8.6.0, )", @@ -2626,7 +2644,8 @@ "type": "Project", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )", - "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )" + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", + "PatternKit.Core": "[0.105.0, )" } }, "workflowframework.extensions.agents": { @@ -2778,6 +2797,7 @@ "workflowframework.extensions.integration": { "type": "Project", "dependencies": { + "PatternKit.Core": "[0.105.0, )", "WorkflowFramework": "[1.0.0, )", "WorkflowFramework.Extensions.Integration.Abstractions": "[1.0.0, )" } @@ -2843,6 +2863,7 @@ "workflowframework.extensions.polly": { "type": "Project", "dependencies": { + "PatternKit.Core": "[0.105.0, )", "Polly.Core": "[8.6.0, )", "WorkflowFramework": "[1.0.0, )" } @@ -3003,6 +3024,12 @@ "Npgsql": "9.0.3" } }, + "PatternKit.Core": { + "type": "CentralTransitive", + "requested": "[0.105.0, )", + "resolved": "0.105.0", + "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==" + }, "Polly.Core": { "type": "CentralTransitive", "requested": "[8.6.0, )",