From 6c414c37a526bc946baa8d97a4e313115e7a4715 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Sat, 30 May 2026 07:45:25 -0500 Subject: [PATCH] feat: add manual task gate pattern --- README.md | 6 +- .../Application/ManualTaskGateBenchmarks.cs | 35 +++ .../order-approval-manual-task-gate.md | 23 ++ docs/examples/toc.yml | 3 + docs/generators/index.md | 1 + docs/generators/manual-task-gate.md | 18 ++ docs/generators/toc.yml | 3 + docs/guides/benchmark-results.md | 10 +- docs/guides/benchmarks.md | 2 + docs/guides/pattern-coverage.md | 1 + docs/index.md | 4 +- docs/patterns/application/manual-task-gate.md | 26 ++ docs/patterns/toc.yml | 2 + .../ManualTaskGates/ManualTaskGate.cs | 229 ++++++++++++++++++ ...rnKitExampleServiceCollectionExtensions.cs | 13 + .../OrderApprovalManualTaskGateDemo.cs | 79 ++++++ .../PatternKitExampleCatalog.cs | 8 + .../PatternKitPatternCatalog.cs | 13 + .../ManualTaskGateAttributes.cs | 14 ++ .../AnalyzerReleases.Unshipped.md | 2 + .../ManualTaskGateGenerator.cs | 154 ++++++++++++ .../OrderApprovalManualTaskGateDemoTests.cs | 84 +++++++ .../PatternKitBenchmarkCoverageTests.cs | 2 +- .../PatternKitPatternCatalogTests.cs | 3 +- .../AbstractionsTests.cs | 32 +++ .../ManualTaskGateGeneratorTests.cs | 144 +++++++++++ .../ManualTaskGates/ManualTaskGateTests.cs | 99 ++++++++ 27 files changed, 1001 insertions(+), 9 deletions(-) create mode 100644 benchmarks/PatternKit.Benchmarks/Application/ManualTaskGateBenchmarks.cs create mode 100644 docs/examples/order-approval-manual-task-gate.md create mode 100644 docs/generators/manual-task-gate.md create mode 100644 docs/patterns/application/manual-task-gate.md create mode 100644 src/PatternKit.Core/Application/ManualTaskGates/ManualTaskGate.cs create mode 100644 src/PatternKit.Examples/ManualTaskGateDemo/OrderApprovalManualTaskGateDemo.cs create mode 100644 src/PatternKit.Generators.Abstractions/ManualTaskGates/ManualTaskGateAttributes.cs create mode 100644 src/PatternKit.Generators/ManualTaskGates/ManualTaskGateGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/ManualTaskGateDemo/OrderApprovalManualTaskGateDemoTests.cs create mode 100644 test/PatternKit.Generators.Tests/ManualTaskGateGeneratorTests.cs create mode 100644 test/PatternKit.Tests/Application/ManualTaskGates/ManualTaskGateTests.cs diff --git a/README.md b/README.md index e003c5c3..bc0a06f3 100644 --- a/README.md +++ b/README.md @@ -473,11 +473,11 @@ var cachedRemoteProxy = Proxy.Create(id => remoteProxy.Execute(id)) --- ## Patterns Table -PatternKit currently tracks 107 production-readiness patterns. Each catalog pattern is represented in tests, documentation, real-world examples, IoC integration, and the BenchmarkDotNet coverage matrix. +PatternKit currently tracks 108 production-readiness patterns. Each catalog pattern is represented in tests, documentation, real-world examples, IoC integration, and the BenchmarkDotNet coverage matrix. | Category | Count | Patterns | | --- | ---: | --- | -| Application Architecture | 22 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Feature Toggle, Identity Map, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object | +| Application Architecture | 23 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Feature Toggle, Identity Map, Manual Task Gate, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object | | Behavioral | 11 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 17 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig | | Creational | 5 | Abstract Factory, Builder, Factory Method, Prototype, Singleton | @@ -497,6 +497,8 @@ BenchmarkDotNet guidance is documented in [docs/guides/benchmarks.md](docs/guide | Adapter | Execution | 59.084 ns | 416 B | 20.479 ns | 80 B | Generated adapter execution was faster and allocated less for shipment adaptation. | | Activity Tracker | Construction | 13.09 ns | 152 B | 12.98 ns | 152 B | Same allocation; generated was slightly faster in this microbenchmark. | | Activity Tracker | Execution | 446.88 ns | 1,656 B | 452.36 ns | 1,656 B | Same allocation; fluent was slightly faster for dashboard loading gates. | +| Manual Task Gate | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Manual Task Gate | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Timeout Manager | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Timeout Manager | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Aggregate Root | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | diff --git a/benchmarks/PatternKit.Benchmarks/Application/ManualTaskGateBenchmarks.cs b/benchmarks/PatternKit.Benchmarks/Application/ManualTaskGateBenchmarks.cs new file mode 100644 index 00000000..f52075e9 --- /dev/null +++ b/benchmarks/PatternKit.Benchmarks/Application/ManualTaskGateBenchmarks.cs @@ -0,0 +1,35 @@ +using BenchmarkDotNet.Attributes; +using PatternKit.Application.ManualTaskGates; +using PatternKit.Examples.ManualTaskGateDemo; + +namespace PatternKit.Benchmarks.Application; + +[BenchmarkCategory("ApplicationArchitecture", "ManualTaskGate")] +public class ManualTaskGateBenchmarks +{ + private static readonly OrderApprovalRequest Request = new( + Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + "REQ-200", + 1250.00m, + "checkout-api"); + + [Benchmark(Baseline = true, Description = "Fluent: create manual task gate")] + [BenchmarkCategory("Fluent", "Construction")] + public ManualTaskGate Fluent_CreateManualTaskGate() + => OrderApprovalManualTaskGates.CreateFluent(); + + [Benchmark(Description = "Generated: create manual task gate")] + [BenchmarkCategory("Generated", "Construction")] + public ManualTaskGate Generated_CreateManualTaskGate() + => GeneratedOrderApprovalManualTaskGate.CreateGenerated(); + + [Benchmark(Description = "Fluent: approve order manual task")] + [BenchmarkCategory("Fluent", "Execution")] + public OrderApprovalSummary Fluent_ApproveOrder() + => OrderApprovalManualTaskGateDemoRunner.RunFluent(Request); + + [Benchmark(Description = "Generated: approve order manual task")] + [BenchmarkCategory("Generated", "Execution")] + public OrderApprovalSummary Generated_ApproveOrder() + => OrderApprovalManualTaskGateDemoRunner.RunGeneratedStatic(Request); +} diff --git a/docs/examples/order-approval-manual-task-gate.md b/docs/examples/order-approval-manual-task-gate.md new file mode 100644 index 00000000..72a6d99e --- /dev/null +++ b/docs/examples/order-approval-manual-task-gate.md @@ -0,0 +1,23 @@ +# Order Approval Manual Task Gate + +This example shows a high-value checkout flow that pauses on manual approval before fulfillment can continue. + +```csharp +var request = new OrderApprovalRequest(orderId, "REQ-200", 1250.00m, "checkout-api"); +var summary = OrderApprovalManualTaskGateDemoRunner.RunFluent(request); +``` + +The source-generated route uses the same workflow through a generated factory: + +```csharp +var gate = GeneratedOrderApprovalManualTaskGate.CreateGenerated(); +var service = new OrderApprovalManualTaskService(gate); +``` + +Import the demo into an existing host with: + +```csharp +services.AddOrderApprovalManualTaskGateDemo(); +``` + +The registration provides `ManualTaskGate`, `OrderApprovalManualTaskService`, and `OrderApprovalManualTaskGateDemoRunner`. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 12863ff2..d1a1e2bc 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -7,6 +7,9 @@ - name: Dashboard Activity Tracker href: dashboard-activity-tracker.md +- name: Order Approval Manual Task Gate + href: order-approval-manual-task-gate.md + - name: Order Reservation Timeout Manager href: order-reservation-timeout-manager.md diff --git a/docs/generators/index.md b/docs/generators/index.md index 82b0506d..84d9dbd9 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -62,6 +62,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Repository**](repository.md) | In-memory repository factories from key selectors | `[GenerateRepository]` | | [**Anti-Corruption Layer**](anti-corruption-layer.md) | External-to-domain translation boundaries with validation | `[GenerateAntiCorruptionLayer]` | | [**Activity Tracker**](activity-tracker.md) | Active-work tracker gates for loading and readiness workflows | `[GenerateActivityTracker]` | +| [**Manual Task Gate**](manual-task-gate.md) | Human approval gates for workflow pauses and manual decisions | `[GenerateManualTaskGate]` | | [**Timeout Manager**](timeout-manager.md) | Deadline registry for expiring pending workflow work | `[GenerateTimeoutManager]` | | [**Audit Log**](audit-log.md) | Append-only audit log factories from key selectors | `[GenerateAuditLog]` | | [**Unit of Work**](unit-of-work.md) | Ordered commit and rollback units | `[GenerateUnitOfWork]` | diff --git a/docs/generators/manual-task-gate.md b/docs/generators/manual-task-gate.md new file mode 100644 index 00000000..a3c4e549 --- /dev/null +++ b/docs/generators/manual-task-gate.md @@ -0,0 +1,18 @@ +# Manual Task Gate Generator + +The Manual Task Gate generator creates a strongly typed factory for `ManualTaskGate` from a partial host type. + +```csharp +using PatternKit.Generators.ManualTaskGates; + +[GenerateManualTaskGate(typeof(Guid), FactoryMethodName = "CreateGenerated", GateName = "order-approval-gate")] +public static partial class GeneratedOrderApprovalManualTaskGate; +``` + +Generated usage: + +```csharp +ManualTaskGate gate = GeneratedOrderApprovalManualTaskGate.CreateGenerated(); +``` + +The host type must be partial. `FactoryMethodName` and `GateName` must be non-empty when provided. diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index b0b03259..fd5d5b3c 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -97,6 +97,9 @@ - name: Materialized View href: materialized-view.md +- name: Manual Task Gate + href: manual-task-gate.md + - name: Iterator href: iterator.md diff --git a/docs/guides/benchmark-results.md b/docs/guides/benchmark-results.md index d49529d8..3ac27ffa 100644 --- a/docs/guides/benchmark-results.md +++ b/docs/guides/benchmark-results.md @@ -17,6 +17,8 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 | Adapter | Execution | 59.084 ns | 416 B | 20.479 ns | 80 B | Generated adapter execution was faster and allocated less for shipment adaptation. | | Activity Tracker | Construction | 13.09 ns | 152 B | 12.98 ns | 152 B | Same allocation; generated was slightly faster in this microbenchmark. | | Activity Tracker | Execution | 446.88 ns | 1,656 B | 452.36 ns | 1,656 B | Same allocation; fluent was slightly faster for dashboard loading gates. | +| Manual Task Gate | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Manual Task Gate | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Timeout Manager | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Timeout Manager | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Aggregate Root | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | @@ -232,11 +234,11 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 ## Coverage Matrix Summary -The coverage matrix currently publishes 107 catalog patterns and 428 pattern route results. Each pattern has four BenchmarkDotNet routes: fluent construction, fluent execution, source-generated construction, and source-generated execution. The reusable hosting integration matrix publishes 9 reusable hosting integration route results for package-level `IServiceCollection` registrations. +The coverage matrix currently publishes 108 catalog patterns and 432 pattern route results. Each pattern has four BenchmarkDotNet routes: fluent construction, fluent execution, source-generated construction, and source-generated execution. The reusable hosting integration matrix publishes 9 reusable hosting integration route results for package-level `IServiceCollection` registrations. | Category | Patterns | Published route results | | --- | ---: | ---: | -| Application Architecture | 22 | 88 | +| Application Architecture | 23 | 92 | | Behavioral | 11 | 44 | | Cloud Architecture | 17 | 68 | | Creational | 5 | 20 | @@ -244,7 +246,7 @@ The coverage matrix currently publishes 107 catalog patterns and 428 pattern rou | Messaging Reliability | 3 | 12 | | Structural | 7 | 28 | -The generator matrix currently publishes 102 generator source route results. +The generator matrix currently publishes 103 generator source route results. ## Hosting Integration Matrix Results @@ -265,6 +267,7 @@ The generator matrix currently publishes 102 generator source route results. | Category | Pattern | Fluent construction | Fluent execution | Generated construction | Generated execution | | --- | --- | --- | --- | --- | --- | | Application Architecture | Activity Tracker | Covered | Covered | Covered | Covered | +| Application Architecture | Manual Task Gate | Covered | Covered | Covered | Covered | | Application Architecture | Timeout Manager | Covered | Covered | Covered | Covered | | Application Architecture | Aggregate Root | Covered | Covered | Covered | Covered | | Application Architecture | Anti-Corruption Layer | Covered | Covered | Covered | Covered | @@ -377,6 +380,7 @@ The generator matrix currently publishes 102 generator source route results. | --- | --- | --- | | ActivityTrackerGenerator | `src/PatternKit.Generators/ActivityTracking/ActivityTrackerGenerator.cs` | Covered | | TimeoutManagerGenerator | `src/PatternKit.Generators/Timeouts/TimeoutManagerGenerator.cs` | Covered | +| ManualTaskGateGenerator | `src/PatternKit.Generators/ManualTaskGates/ManualTaskGateGenerator.cs` | Covered | | AggregateCommandHandlerGenerator | `src/PatternKit.Generators/Aggregates/AggregateCommandHandlerGenerator.cs` | Covered | | AdapterGenerator | `src/PatternKit.Generators/Adapter/AdapterGenerator.cs` | Covered | | AmbassadorGenerator | `src/PatternKit.Generators/Ambassador/AmbassadorGenerator.cs` | Covered | diff --git a/docs/guides/benchmarks.md b/docs/guides/benchmarks.md index 7d69b257..f1592b32 100644 --- a/docs/guides/benchmarks.md +++ b/docs/guides/benchmarks.md @@ -34,6 +34,8 @@ The following numbers were captured on Windows 11, Intel Core i9-14900K, .NET SD | Adapter | Execution | 59.084 ns | 416 B | 20.479 ns | 80 B | Generated adapter execution was faster and allocated less for shipment adaptation. | | Activity Tracker | Construction | 13.09 ns | 152 B | 12.98 ns | 152 B | Same allocation; generated was slightly faster in this microbenchmark. | | Activity Tracker | Execution | 446.88 ns | 1,656 B | 452.36 ns | 1,656 B | Same allocation; fluent was slightly faster for dashboard loading gates. | +| Manual Task Gate | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Manual Task Gate | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Timeout Manager | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Timeout Manager | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Aggregate Root | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 1b9cb8fc..2ca9f034 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -125,6 +125,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Application Architecture | Materialized View | `IMaterializedView` and `MaterializedView` | Materialized View generator | | Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer` | Anti-Corruption Layer generator | | Application Architecture | Activity Tracker | `ActivityTracker` | Activity Tracker generator | +| Application Architecture | Manual Task Gate | `ManualTaskGate` | Manual Task Gate generator | | Application Architecture | Timeout Manager | `TimeoutManager` | Timeout Manager generator | ## Research Baselines diff --git a/docs/index.md b/docs/index.md index 5e98ae16..c145ede7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,11 +66,11 @@ if (parser.Execute("123", out var value)) ## 📚 Available Patterns -PatternKit covers 107 production-readiness patterns with fluent APIs, source-generated routes where applicable, IoC integration examples, TinyBDD coverage, and BenchmarkDotNet coverage-matrix validation: +PatternKit covers 108 production-readiness patterns with fluent APIs, source-generated routes where applicable, IoC integration examples, TinyBDD coverage, and BenchmarkDotNet coverage-matrix validation: | Category | Count | Patterns | | --- | ---: | --- | -| Application Architecture | 22 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Feature Toggle, Identity Map, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object | +| Application Architecture | 23 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Feature Toggle, Identity Map, Manual Task Gate, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object | | Behavioral | 11 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 17 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig | | Creational | 5 | Abstract Factory, Builder, Factory Method, Prototype, Singleton | diff --git a/docs/patterns/application/manual-task-gate.md b/docs/patterns/application/manual-task-gate.md new file mode 100644 index 00000000..6a33d1fc --- /dev/null +++ b/docs/patterns/application/manual-task-gate.md @@ -0,0 +1,26 @@ +# Human Approval / Manual Task Gate + +Manual Task Gate tracks human-owned work that blocks a workflow until a person approves, rejects, cancels, or completes the task. Use it for high-value order reviews, exception handling, finance approvals, manual fraud checks, and operations where automated orchestration must pause on an explicit decision. + +`ManualTaskGate` provides the fluent runtime path: + +```csharp +var gate = ManualTaskGate + .Create("order-approval-gate") + .Build(); + +gate.Open(orderId, "Approve high value order", "order-approvals", requestId); +var decision = gate.Approve(orderId, "case-manager", "Approved for fulfillment."); +var state = gate.GetGateState(); +``` + +The gate remains blocked while any task has `ManualTaskStatus.Pending`. Decisions are retained in the snapshot so operators can inspect which tasks were approved, rejected, or canceled. + +Use the source-generated path when you want a reusable named gate factory: + +```csharp +[GenerateManualTaskGate(typeof(Guid), FactoryMethodName = "CreateGenerated", GateName = "order-approval-gate")] +public static partial class GeneratedOrderApprovalManualTaskGate; +``` + +The generated factory returns the same `ManualTaskGate` fluent object without handwritten boilerplate. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 11ca34b4..fd05c254 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -439,6 +439,8 @@ href: application/context-map.md - name: Table Data Gateway href: application/table-data-gateway.md + - name: Manual Task Gate + href: application/manual-task-gate.md - name: Timeout Manager href: application/timeout-manager.md - name: Event Sourcing diff --git a/src/PatternKit.Core/Application/ManualTaskGates/ManualTaskGate.cs b/src/PatternKit.Core/Application/ManualTaskGates/ManualTaskGate.cs new file mode 100644 index 00000000..826b7df2 --- /dev/null +++ b/src/PatternKit.Core/Application/ManualTaskGates/ManualTaskGate.cs @@ -0,0 +1,229 @@ +namespace PatternKit.Application.ManualTaskGates; + +/// +/// Tracks human-owned tasks that block a workflow until they are approved, rejected, canceled, or completed. +/// +public sealed class ManualTaskGate + where TKey : notnull +{ + private readonly object _gate = new(); + private readonly Dictionary> _tasks; + private readonly Func _clock; + + private ManualTaskGate(string name, IEqualityComparer keyComparer, Func clock) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Manual task gate name cannot be null, empty, or whitespace.", nameof(name)); + + Name = name; + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _tasks = new Dictionary>(keyComparer ?? throw new ArgumentNullException(nameof(keyComparer))); + } + + public string Name { get; } + + public bool IsBlocked => PendingCount > 0; + + public int PendingCount + { + get + { + lock (_gate) + return _tasks.Values.Count(static task => task.IsBlocking); + } + } + + public ManualTaskRecord Open(TKey key, string taskName, string? assignee = null, string? correlationId = null) + { + if (string.IsNullOrWhiteSpace(taskName)) + throw new ArgumentException("Manual task name cannot be null, empty, or whitespace.", nameof(taskName)); + + var record = new ManualTaskRecord( + key, + taskName, + assignee, + correlationId, + ManualTaskStatus.Pending, + _clock(), + null, + null, + null); + + lock (_gate) + _tasks[key] = record; + + return record; + } + + public ManualTaskRecord? Approve(TKey key, string actor, string? decisionNote = null) + => Decide(key, ManualTaskStatus.Approved, actor, decisionNote); + + public ManualTaskRecord? Reject(TKey key, string actor, string? decisionNote = null) + => Decide(key, ManualTaskStatus.Rejected, actor, decisionNote); + + public ManualTaskRecord? Cancel(TKey key, string actor, string? decisionNote = null) + => Decide(key, ManualTaskStatus.Canceled, actor, decisionNote); + + public bool Complete(TKey key) + { + lock (_gate) + return _tasks.Remove(key); + } + + public IReadOnlyList> Snapshot() + { + lock (_gate) + return _tasks.Values + .OrderBy(static task => task.OpenedAt) + .ThenBy(static task => task.TaskName, StringComparer.Ordinal) + .ToArray(); + } + + public ManualTaskGateState GetGateState() + { + lock (_gate) + { + var tasks = _tasks.Values + .OrderBy(static task => task.OpenedAt) + .ThenBy(static task => task.TaskName, StringComparer.Ordinal) + .ToArray(); + var pending = tasks.Where(static task => task.IsBlocking).ToArray(); + + return new(Name, pending.Length > 0, pending.Length, pending, tasks); + } + } + + public static Builder Create(string name = "manual-task-gate") => new(name); + + private ManualTaskRecord? Decide(TKey key, ManualTaskStatus status, string actor, string? decisionNote) + { + if (string.IsNullOrWhiteSpace(actor)) + throw new ArgumentException("Manual task decision actor cannot be null, empty, or whitespace.", nameof(actor)); + + lock (_gate) + { + if (!_tasks.TryGetValue(key, out var current)) + return null; + + if (!current.IsBlocking) + return current; + + var decided = current.WithDecision(status, actor, decisionNote, _clock()); + _tasks[key] = decided; + return decided; + } + } + + public sealed class Builder + { + private readonly string _name; + private IEqualityComparer _keyComparer = EqualityComparer.Default; + private Func _clock = static () => DateTimeOffset.UtcNow; + + internal Builder(string name) + => _name = name; + + public Builder WithKeyComparer(IEqualityComparer keyComparer) + { + _keyComparer = keyComparer ?? throw new ArgumentNullException(nameof(keyComparer)); + return this; + } + + public Builder WithClock(Func clock) + { + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + return this; + } + + public ManualTaskGate Build() + => new(_name, _keyComparer, _clock); + } +} + +public enum ManualTaskStatus +{ + Pending, + Approved, + Rejected, + Canceled +} + +public sealed class ManualTaskRecord + where TKey : notnull +{ + public ManualTaskRecord( + TKey key, + string taskName, + string? assignee, + string? correlationId, + ManualTaskStatus status, + DateTimeOffset openedAt, + DateTimeOffset? decidedAt, + string? decidedBy, + string? decisionNote) + { + if (string.IsNullOrWhiteSpace(taskName)) + throw new ArgumentException("Manual task name cannot be null, empty, or whitespace.", nameof(taskName)); + + Key = key; + TaskName = taskName; + Assignee = assignee; + CorrelationId = correlationId; + Status = status; + OpenedAt = openedAt; + DecidedAt = decidedAt; + DecidedBy = decidedBy; + DecisionNote = decisionNote; + } + + public TKey Key { get; } + + public string TaskName { get; } + + public string? Assignee { get; } + + public string? CorrelationId { get; } + + public ManualTaskStatus Status { get; } + + public DateTimeOffset OpenedAt { get; } + + public DateTimeOffset? DecidedAt { get; } + + public string? DecidedBy { get; } + + public string? DecisionNote { get; } + + public bool IsBlocking => Status == ManualTaskStatus.Pending; + + internal ManualTaskRecord WithDecision(ManualTaskStatus status, string actor, string? decisionNote, DateTimeOffset decidedAt) + => new(Key, TaskName, Assignee, CorrelationId, status, OpenedAt, decidedAt, actor, decisionNote); +} + +public sealed class ManualTaskGateState + where TKey : notnull +{ + public ManualTaskGateState( + string gateName, + bool isBlocked, + int pendingCount, + IReadOnlyList> pendingTasks, + IReadOnlyList> allTasks) + { + GateName = gateName; + IsBlocked = isBlocked; + PendingCount = pendingCount; + PendingTasks = pendingTasks ?? throw new ArgumentNullException(nameof(pendingTasks)); + AllTasks = allTasks ?? throw new ArgumentNullException(nameof(allTasks)); + } + + public string GateName { get; } + + public bool IsBlocked { get; } + + public int PendingCount { get; } + + public IReadOnlyList> PendingTasks { get; } + + public IReadOnlyList> AllTasks { get; } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 0684ffe3..358288e2 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using PatternKit.Application.ActivityTracking; using PatternKit.Application.AntiCorruption; +using PatternKit.Application.ManualTaskGates; using PatternKit.Application.Specification; using PatternKit.Application.Timeouts; using PatternKit.Behavioral.Chain; @@ -53,6 +54,7 @@ using PatternKit.Examples.HealthEndpointMonitoringDemo; using PatternKit.Examples.IdentityMapDemo; using PatternKit.Examples.LeaderElectionDemo; +using PatternKit.Examples.ManualTaskGateDemo; using PatternKit.Examples.MaterializedViewDemo; using PatternKit.Examples.MementoDemo; using PatternKit.Examples.Messaging; @@ -246,6 +248,7 @@ public sealed record CommerceBackendsForFrontendsExample(CommerceBackendsForFron public sealed record InventoryAmbassadorExample(InventoryAmbassadorDemoRunner Runner, InventoryAmbassadorService Service); public sealed record WarehouseLeaderElectionExample(WarehouseLeaderElectionDemoRunner Runner, WarehouseLeaderElectionService Service); public sealed record WarehouseSchedulerAgentSupervisorExample(WarehouseSchedulerDemoRunner Runner, WarehouseSchedulerService Service); +public sealed record OrderApprovalManualTaskGatePatternExample(ManualTaskGate Gate, OrderApprovalManualTaskGateDemoRunner Runner); public sealed record OrderReservationTimeoutPatternExample(TimeoutManager Manager, OrderReservationTimeoutDemoRunner Runner); /// @@ -361,6 +364,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddInventoryAmbassadorExample() .AddWarehouseLeaderElectionExample() .AddWarehouseSchedulerAgentSupervisorExample() + .AddOrderApprovalManualTaskGatePatternExample() .AddOrderReservationTimeoutPatternExample(); public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services) @@ -1317,6 +1321,15 @@ public static IServiceCollection AddOrderReservationTimeoutPatternExample(this I return services.RegisterExample("Order Reservation Timeout Manager", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddOrderApprovalManualTaskGatePatternExample(this IServiceCollection services) + { + services.AddOrderApprovalManualTaskGateDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService())); + return services.RegisterExample("Order Approval Manual Task Gate", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + private static IServiceCollection RegisterExample( this IServiceCollection services, string name, diff --git a/src/PatternKit.Examples/ManualTaskGateDemo/OrderApprovalManualTaskGateDemo.cs b/src/PatternKit.Examples/ManualTaskGateDemo/OrderApprovalManualTaskGateDemo.cs new file mode 100644 index 00000000..a22f3c92 --- /dev/null +++ b/src/PatternKit.Examples/ManualTaskGateDemo/OrderApprovalManualTaskGateDemo.cs @@ -0,0 +1,79 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.ManualTaskGates; +using PatternKit.Generators.ManualTaskGates; + +namespace PatternKit.Examples.ManualTaskGateDemo; + +public sealed record OrderApprovalRequest(Guid OrderId, string RequestId, decimal Total, string SubmittedBy); + +public sealed record OrderApprovalSummary(bool IsBlocked, int PendingApprovals, ManualTaskStatus? Decision); + +public static partial class OrderApprovalManualTaskGates +{ + public static ManualTaskGate CreateFluent() + => ManualTaskGate.Create("order-approval-gate").Build(); +} + +[GenerateManualTaskGate(typeof(Guid), FactoryMethodName = "CreateGenerated", GateName = "order-approval-gate")] +public static partial class GeneratedOrderApprovalManualTaskGate; + +public sealed class OrderApprovalManualTaskService(ManualTaskGate gate) +{ + public ManualTaskRecord Submit(OrderApprovalRequest request) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + + return gate.Open( + request.OrderId, + $"Approve order total {request.Total:C}", + "order-approvals", + request.RequestId); + } + + public OrderApprovalSummary Approve(Guid orderId, string actor) + { + var decision = gate.Approve(orderId, actor, "Approved for fulfillment."); + return new(gate.IsBlocked, gate.PendingCount, decision?.Status); + } + + public OrderApprovalSummary Reject(Guid orderId, string actor) + { + var decision = gate.Reject(orderId, actor, "Rejected by manual review."); + return new(gate.IsBlocked, gate.PendingCount, decision?.Status); + } +} + +public sealed class OrderApprovalManualTaskGateDemoRunner(OrderApprovalManualTaskService service) +{ + public OrderApprovalSummary RunGenerated(OrderApprovalRequest request) + { + service.Submit(request); + return service.Approve(request.OrderId, "case-manager"); + } + + public static OrderApprovalSummary RunFluent(OrderApprovalRequest request) + { + var service = new OrderApprovalManualTaskService(OrderApprovalManualTaskGates.CreateFluent()); + service.Submit(request); + return service.Approve(request.OrderId, "case-manager"); + } + + public static OrderApprovalSummary RunGeneratedStatic(OrderApprovalRequest request) + { + var service = new OrderApprovalManualTaskService(GeneratedOrderApprovalManualTaskGate.CreateGenerated()); + service.Submit(request); + return service.Approve(request.OrderId, "case-manager"); + } +} + +public static class OrderApprovalManualTaskGateServiceCollectionExtensions +{ + public static IServiceCollection AddOrderApprovalManualTaskGateDemo(this IServiceCollection services) + { + services.AddSingleton(static _ => GeneratedOrderApprovalManualTaskGate.CreateGenerated()); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 55428676..17a8acf4 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -664,6 +664,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["ActivityTracker"], ["tracked active work", "source-generated tracker factory", "DI composition"]), + Descriptor( + "Order Approval Manual Task Gate", + "src/PatternKit.Examples/ManualTaskGateDemo/OrderApprovalManualTaskGateDemo.cs", + "test/PatternKit.Examples.Tests/ManualTaskGateDemo/OrderApprovalManualTaskGateDemoTests.cs", + "docs/examples/order-approval-manual-task-gate.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["ManualTaskGate"], + ["manual approval gate", "source-generated gate factory", "DI composition"]), Descriptor( "Order Reservation Timeout Manager", "src/PatternKit.Examples/TimeoutManagerDemo/OrderReservationTimeoutDemo.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 7a5440de..05ceee53 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -1428,6 +1428,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/ActivityTrackingDemo/DashboardActivityTrackerDemoTests.cs", ["fluent activity gate", "generated tracker factory", "DI-importable loading gate example"]), + Pattern("Manual Task Gate", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/manual-task-gate.md", + "src/PatternKit.Core/Application/ManualTaskGates/ManualTaskGate.cs", + "test/PatternKit.Tests/Application/ManualTaskGates/ManualTaskGateTests.cs", + "docs/generators/manual-task-gate.md", + "src/PatternKit.Generators/ManualTaskGates/ManualTaskGateGenerator.cs", + "test/PatternKit.Generators.Tests/ManualTaskGateGeneratorTests.cs", + null, + "docs/examples/order-approval-manual-task-gate.md", + "src/PatternKit.Examples/ManualTaskGateDemo/OrderApprovalManualTaskGateDemo.cs", + "test/PatternKit.Examples.Tests/ManualTaskGateDemo/OrderApprovalManualTaskGateDemoTests.cs", + ["fluent human approval gate", "generated manual task gate factory", "DI-importable order approval example"]), + Pattern("Timeout Manager", PatternFamily.ApplicationArchitecture, "docs/patterns/application/timeout-manager.md", "src/PatternKit.Core/Application/Timeouts/TimeoutManager.cs", diff --git a/src/PatternKit.Generators.Abstractions/ManualTaskGates/ManualTaskGateAttributes.cs b/src/PatternKit.Generators.Abstractions/ManualTaskGates/ManualTaskGateAttributes.cs new file mode 100644 index 00000000..c0aef724 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/ManualTaskGates/ManualTaskGateAttributes.cs @@ -0,0 +1,14 @@ +namespace PatternKit.Generators.ManualTaskGates; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateManualTaskGateAttribute : Attribute +{ + public GenerateManualTaskGateAttribute(Type keyType) + => KeyType = keyType; + + public Type KeyType { get; } + + public string FactoryMethodName { get; set; } = "Create"; + + public string GateName { get; set; } = "manual-task-gate"; +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index d7fc3e86..fdb9d26e 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -431,3 +431,5 @@ PKVO002 | PatternKit.Generators.ValueObjects | Error | Value Object host must be PKVO003 | PatternKit.Generators.ValueObjects | Error | Value Object must declare at least one component. PKTM001 | PatternKit.Generators.Timeouts | Error | Timeout Manager host must be partial. PKTM002 | PatternKit.Generators.Timeouts | Error | Timeout Manager key type is invalid. +PKMTG001 | PatternKit.Generators.ManualTaskGates | Error | Manual Task Gate host must be partial. +PKMTG002 | PatternKit.Generators.ManualTaskGates | Error | Manual Task Gate configuration is invalid. diff --git a/src/PatternKit.Generators/ManualTaskGates/ManualTaskGateGenerator.cs b/src/PatternKit.Generators/ManualTaskGates/ManualTaskGateGenerator.cs new file mode 100644 index 00000000..3d307142 --- /dev/null +++ b/src/PatternKit.Generators/ManualTaskGates/ManualTaskGateGenerator.cs @@ -0,0 +1,154 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace PatternKit.Generators.ManualTaskGates; + +[Generator] +public sealed class ManualTaskGateGenerator : IIncrementalGenerator +{ + private const string AttributeName = "PatternKit.Generators.ManualTaskGates.GenerateManualTaskGateAttribute"; + + private static readonly SymbolDisplayFormat TypeFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKMTG001", + "Manual Task Gate host must be partial", + "Type '{0}' is marked with [GenerateManualTaskGate] but is not declared as partial", + "PatternKit.Generators.ManualTaskGates", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidConfiguration = new( + "PKMTG002", + "Manual Task Gate configuration is invalid", + "Manual Task Gate '{0}' must have non-empty FactoryMethodName and GateName values", + "PatternKit.Generators.ManualTaskGates", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + AttributeName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(static attribute => + attribute.AttributeClass?.ToDisplayString() == AttributeName); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate(SourceProductionContext context, INamedTypeSymbol type, TypeDeclarationSyntax node, AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var keyType = attribute.ConstructorArguments.Length >= 1 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + if (keyType is null || keyType.TypeKind == TypeKind.Error) + return; + + var factoryMethodName = GetNamedString(attribute, "FactoryMethodName") ?? "Create"; + var gateName = GetNamedString(attribute, "GateName") ?? "manual-task-gate"; + if (string.IsNullOrWhiteSpace(factoryMethodName) || string.IsNullOrWhiteSpace(gateName)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidConfiguration, node.Identifier.GetLocation(), type.Name)); + return; + } + + context.AddSource($"{type.Name}.ManualTaskGate.g.cs", SourceText.From( + GenerateSource(type, keyType, factoryMethodName, gateName), + Encoding.UTF8)); + } + + private static string GenerateSource(INamedTypeSymbol type, INamedTypeSymbol keyType, string factoryMethodName, string gateName) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var keyTypeName = keyType.ToDisplayString(TypeFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + var indent = string.Empty; + foreach (var containingType in GetContainingTypes(type)) + { + AppendTypeDeclaration(sb, containingType, indent); + sb.Append(indent).AppendLine("{"); + indent += " "; + } + + AppendTypeDeclaration(sb, type, indent); + sb.Append(indent).AppendLine("{"); + var memberIndent = indent + " "; + var bodyIndent = memberIndent + " "; + sb.Append(memberIndent).Append("public static global::PatternKit.Application.ManualTaskGates.ManualTaskGate<").Append(keyTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.Append(memberIndent).AppendLine("{"); + sb.Append(bodyIndent).Append("return global::PatternKit.Application.ManualTaskGates.ManualTaskGate<").Append(keyTypeName).Append(">.Create(\"").Append(Escape(gateName)).AppendLine("\").Build();"); + sb.Append(memberIndent).AppendLine("}"); + sb.Append(indent).AppendLine("}"); + while (indent.Length > 0) + { + indent = indent.Substring(0, indent.Length - 4); + sb.Append(indent).AppendLine("}"); + } + + return sb.ToString(); + } + + private static IReadOnlyList GetContainingTypes(INamedTypeSymbol type) + { + var stack = new Stack(); + for (var current = type.ContainingType; current is not null; current = current.ContainingType) + stack.Push(current); + return stack.ToArray(); + } + + private static void AppendTypeDeclaration(StringBuilder sb, INamedTypeSymbol type, string indent) + { + sb.Append(indent).Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + if (type.IsStatic) + sb.Append("static "); + else if (type.IsAbstract && type.TypeKind == TypeKind.Class) + sb.Append("abstract "); + else if (type.IsSealed && type.TypeKind == TypeKind.Class) + sb.Append("sealed "); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + } + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; +} diff --git a/test/PatternKit.Examples.Tests/ManualTaskGateDemo/OrderApprovalManualTaskGateDemoTests.cs b/test/PatternKit.Examples.Tests/ManualTaskGateDemo/OrderApprovalManualTaskGateDemoTests.cs new file mode 100644 index 00000000..c7c07056 --- /dev/null +++ b/test/PatternKit.Examples.Tests/ManualTaskGateDemo/OrderApprovalManualTaskGateDemoTests.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.ManualTaskGates; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.ManualTaskGateDemo; +using TinyBDD; + +namespace PatternKit.Examples.Tests.ManualTaskGateDemo; + +public sealed class OrderApprovalManualTaskGateDemoTests +{ + [Scenario("Fluent manual task gate approves order reviews")] + [Fact] + public void Fluent_Manual_Task_Gate_Approves_Order_Reviews() + { + var request = CreateRequest(); + var summary = OrderApprovalManualTaskGateDemoRunner.RunFluent(request); + + ScenarioExpect.False(summary.IsBlocked); + ScenarioExpect.Equal(0, summary.PendingApprovals); + ScenarioExpect.Equal(ManualTaskStatus.Approved, summary.Decision); + } + + [Scenario("Generated manual task gate matches fluent behavior")] + [Fact] + public void Generated_Manual_Task_Gate_Matches_Fluent_Behavior() + { + var request = CreateRequest(); + + var fluent = OrderApprovalManualTaskGateDemoRunner.RunFluent(request); + var generated = OrderApprovalManualTaskGateDemoRunner.RunGeneratedStatic(request); + + ScenarioExpect.Equal(fluent.IsBlocked, generated.IsBlocked); + ScenarioExpect.Equal(fluent.PendingApprovals, generated.PendingApprovals); + ScenarioExpect.Equal(fluent.Decision, generated.Decision); + } + + [Scenario("Order approval manual task service rejects invalid submissions and rejected decisions")] + [Fact] + public void Order_Approval_Manual_Task_Service_Rejects_Invalid_Submissions_And_Rejected_Decisions() + { + var service = new OrderApprovalManualTaskService(OrderApprovalManualTaskGates.CreateFluent()); + var request = CreateRequest(); + + service.Submit(request); + var rejected = service.Reject(request.OrderId, "case-manager"); + + ScenarioExpect.Equal(ManualTaskStatus.Rejected, rejected.Decision); + ScenarioExpect.False(rejected.IsBlocked); + ScenarioExpect.Throws(() => service.Submit(null!)); + } + + [Scenario("ServiceCollection imports manual task gate example")] + [Fact] + public void ServiceCollection_Imports_Manual_Task_Gate_Example() + { + var services = new ServiceCollection(); + services.AddOrderApprovalManualTaskGateDemo(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var runner = provider.GetRequiredService(); + var summary = runner.RunGenerated(CreateRequest()); + + ScenarioExpect.Equal(ManualTaskStatus.Approved, summary.Decision); + ScenarioExpect.NotNull(provider.GetRequiredService>()); + } + + [Scenario("Aggregate examples import manual task gate example")] + [Fact] + public void Aggregate_Examples_Import_Manual_Task_Gate_Example() + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var example = provider.GetRequiredService(); + var summary = example.Runner.RunGenerated(CreateRequest()); + + ScenarioExpect.Equal(ManualTaskStatus.Approved, summary.Decision); + ScenarioExpect.NotNull(example.Gate); + } + + private static OrderApprovalRequest CreateRequest() + => new(Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), "REQ-200", 1250.00m, "checkout-api"); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs index 2b68279f..64e3b7c2 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs @@ -107,7 +107,7 @@ public Task Published_Benchmark_Results_Include_Every_Catalog_Pattern() .Then("every catalog pattern appears in the benchmark results matrix", ctx => ScenarioExpect.Empty(ctx.MissingPatterns)) .And("the guide publishes the route result total", ctx => - ScenarioExpect.Contains("428 pattern route results", ctx.ResultsGuide)) + ScenarioExpect.Contains("432 pattern route results", ctx.ResultsGuide)) .AssertPassed(); [Scenario("Published benchmark results include reusable hosting integrations")] diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 5990058a..b6609419 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -120,6 +120,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Materialized View", "Anti-Corruption Layer", "Activity Tracker", + "Manual Task Gate", "Timeout Manager" ]; @@ -164,7 +165,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() ScenarioExpect.Equal(41, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(17, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); - ScenarioExpect.Equal(22, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(23, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Generators.Tests/AbstractionsTests.cs b/test/PatternKit.Generators.Tests/AbstractionsTests.cs index a8108746..381f9730 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsTests.cs @@ -8,6 +8,38 @@ namespace PatternKit.Generators.Tests; /// public class AbstractionsTests { + #region GenerateManualTaskGateAttribute Tests + + [Scenario("GenerateManualTaskGateAttribute Constructor Sets Properties")] + [Fact] + public void GenerateManualTaskGateAttribute_Constructor_Sets_Properties() + { + var attr = new PatternKit.Generators.ManualTaskGates.GenerateManualTaskGateAttribute(typeof(Guid)) + { + FactoryMethodName = "CreateApprovalGate", + GateName = "approval-gate" + }; + + ScenarioExpect.Equal(typeof(Guid), attr.KeyType); + ScenarioExpect.Equal("CreateApprovalGate", attr.FactoryMethodName); + ScenarioExpect.Equal("approval-gate", attr.GateName); + } + + [Scenario("GenerateManualTaskGateAttribute Has Correct AttributeUsage")] + [Fact] + public void GenerateManualTaskGateAttribute_Has_Correct_AttributeUsage() + { + var usage = typeof(PatternKit.Generators.ManualTaskGates.GenerateManualTaskGateAttribute) + .GetCustomAttributes(typeof(AttributeUsageAttribute), false) + .Cast() + .Single(); + + ScenarioExpect.Equal(AttributeTargets.Class | AttributeTargets.Struct, usage.ValidOn); + ScenarioExpect.False(usage.Inherited); + } + + #endregion + #region GenerateTimeoutManagerAttribute Tests [Scenario("GenerateTimeoutManagerAttribute Constructor Sets Properties")] diff --git a/test/PatternKit.Generators.Tests/ManualTaskGateGeneratorTests.cs b/test/PatternKit.Generators.Tests/ManualTaskGateGeneratorTests.cs new file mode 100644 index 00000000..e24616f3 --- /dev/null +++ b/test/PatternKit.Generators.Tests/ManualTaskGateGeneratorTests.cs @@ -0,0 +1,144 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Generators.ManualTaskGates; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Manual Task Gate generator")] +public sealed partial class ManualTaskGateGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates manual task gate factory")] + [Fact] + public Task Generates_Manual_Task_Gate_Factory() + => Given("a manual task gate declaration", () => Compile(""" + using PatternKit.Generators.ManualTaskGates; + namespace Demo; + [GenerateManualTaskGate(typeof(string), FactoryMethodName = "Build", GateName = "approval-gate")] + public static partial class ApprovalGates; + """)) + .Then("the generated source creates the configured gate", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("public static partial class ApprovalGates", source); + ScenarioExpect.Contains("ManualTaskGate Build()", source); + ScenarioExpect.Contains("ManualTaskGate.Create(\"approval-gate\").Build()", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid manual task gate declarations")] + [Fact] + public Task Reports_Diagnostics_For_Invalid_Manual_Task_Gate_Declarations() + => Given("invalid manual task gate declarations", () => new[] + { + Compile(""" + using PatternKit.Generators.ManualTaskGates; + [GenerateManualTaskGate(typeof(string))] + public static class ApprovalGates; + """), + Compile(""" + using PatternKit.Generators.ManualTaskGates; + [GenerateManualTaskGate(typeof(string), FactoryMethodName = "")] + public static partial class ApprovalGates; + """), + Compile(""" + using PatternKit.Generators.ManualTaskGates; + [GenerateManualTaskGate(typeof(string), GateName = " ")] + public static partial class ApprovalGates; + """) + }) + .Then("diagnostics identify the invalid declarations", results => + { + ScenarioExpect.Contains(results[0].Diagnostics, diagnostic => diagnostic.Id == "PKMTG001"); + ScenarioExpect.Contains(results[1].Diagnostics, diagnostic => diagnostic.Id == "PKMTG002"); + ScenarioExpect.Contains(results[2].Diagnostics, diagnostic => diagnostic.Id == "PKMTG002"); + }) + .AssertPassed(); + + [Scenario("Generates manual task gate defaults and nested host wrappers")] + [Fact] + public Task Generates_Manual_Task_Gate_Defaults_And_Nested_Host_Wrappers() + => Given("nested manual task gate declarations", () => Compile(""" + using PatternKit.Generators.ManualTaskGates; + namespace Demo; + public static partial class FulfillmentModule + { + internal abstract partial class Gates + { + [GenerateManualTaskGate(typeof(System.Guid), GateName = "approval\\\"gate")] + private sealed partial class ManualApprovals; + } + } + """)) + .Then("generated sources preserve containing partial type wrappers", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("public static partial class FulfillmentModule", source); + ScenarioExpect.Contains("internal abstract partial class Gates", source); + ScenarioExpect.Contains("private sealed partial class ManualApprovals", source); + ScenarioExpect.Contains("ManualTaskGate.Create(\"approval\\\\\\\"gate\")", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Generates manual task gate factory for global namespace struct host")] + [Fact] + public Task Generates_Manual_Task_Gate_Factory_For_Global_Namespace_Struct_Host() + => Given("a struct manual task gate host without a namespace", () => Compile(""" + using PatternKit.Generators.ManualTaskGates; + [GenerateManualTaskGate(typeof(int))] + internal partial struct ApprovalGates; + """)) + .Then("the generated source preserves the struct host shape", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("internal partial struct ApprovalGates", source); + ScenarioExpect.Contains("ManualTaskGate Create()", source); + ScenarioExpect.Contains("ManualTaskGate.Create(\"manual-task-gate\").Build()", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Skips manual task gate generation for malformed key type")] + [Fact] + public Task Skips_Manual_Task_Gate_Generation_For_Malformed_Key_Type() + => Given("a manual task gate declaration with an unresolved key type", () => Compile(""" + using PatternKit.Generators.ManualTaskGates; + [GenerateManualTaskGate(typeof(MissingKey))] + public static partial class MissingGates; + """)) + .Then("no generated source is produced by the generator", result => + { + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Empty(result.GeneratedSources); + ScenarioExpect.False(result.EmitSuccess); + }) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "ManualTaskGateGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(PatternKit.Application.ManualTaskGates.ManualTaskGate<>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new ManualTaskGateGenerator(), out var run, out var updated); + var result = run.Results.Single(); + var emit = updated.Emit(Stream.Null); + return new GeneratorResult( + result.Diagnostics.ToArray(), + result.GeneratedSources.Select(static source => source.SourceText.ToString()).ToArray(), + emit.Success, + emit.Diagnostics.Select(static diagnostic => diagnostic.ToString()).ToArray()); + } + + private sealed record GeneratorResult( + IReadOnlyList Diagnostics, + IReadOnlyList GeneratedSources, + bool EmitSuccess, + IReadOnlyList EmitDiagnostics); +} diff --git a/test/PatternKit.Tests/Application/ManualTaskGates/ManualTaskGateTests.cs b/test/PatternKit.Tests/Application/ManualTaskGates/ManualTaskGateTests.cs new file mode 100644 index 00000000..d73be80c --- /dev/null +++ b/test/PatternKit.Tests/Application/ManualTaskGates/ManualTaskGateTests.cs @@ -0,0 +1,99 @@ +using PatternKit.Application.ManualTaskGates; +using TinyBDD; + +namespace PatternKit.Tests.Application.ManualTaskGates; + +public sealed class ManualTaskGateTests +{ + [Scenario("Manual task gate opens and approves blocking work")] + [Fact] + public void Manual_Task_Gate_Opens_And_Approves_Blocking_Work() + { + var now = new DateTimeOffset(2026, 5, 30, 9, 0, 0, TimeSpan.Zero); + var gate = ManualTaskGate.Create("checkout-approvals") + .WithClock(() => now) + .Build(); + + var task = gate.Open("ORDER-1", "Approve high value order", "fraud-team", "REQ-1"); + + ScenarioExpect.True(gate.IsBlocked); + ScenarioExpect.Equal("ORDER-1", task.Key); + ScenarioExpect.Equal("fraud-team", task.Assignee); + ScenarioExpect.Equal("REQ-1", task.CorrelationId); + ScenarioExpect.Equal(ManualTaskStatus.Pending, task.Status); + ScenarioExpect.True(task.IsBlocking); + + var approved = gate.Approve("ORDER-1", "case-manager", "Looks valid."); + + ScenarioExpect.NotNull(approved); + ScenarioExpect.Equal(ManualTaskStatus.Approved, approved.Status); + ScenarioExpect.Equal("case-manager", approved.DecidedBy); + ScenarioExpect.Equal("Looks valid.", approved.DecisionNote); + ScenarioExpect.False(gate.IsBlocked); + } + + [Scenario("Manual task gate tracks rejected canceled and completed work")] + [Fact] + public void Manual_Task_Gate_Tracks_Rejected_Canceled_And_Completed_Work() + { + var now = new DateTimeOffset(2026, 5, 30, 9, 0, 0, TimeSpan.Zero); + var gate = ManualTaskGate.Create() + .WithClock(() => now) + .WithKeyComparer(StringComparer.OrdinalIgnoreCase) + .Build(); + + gate.Open("ORDER-1", "Review address"); + gate.Open("ORDER-2", "Review payment"); + + var rejected = gate.Reject("order-1", "risk-agent"); + var canceled = gate.Cancel("ORDER-2", "scheduler", "expired"); + + ScenarioExpect.NotNull(rejected); + ScenarioExpect.NotNull(canceled); + ScenarioExpect.Equal(ManualTaskStatus.Rejected, rejected.Status); + ScenarioExpect.Equal(ManualTaskStatus.Canceled, canceled.Status); + ScenarioExpect.False(gate.IsBlocked); + ScenarioExpect.True(gate.Complete("ORDER-1")); + ScenarioExpect.False(gate.Complete("ORDER-1")); + ScenarioExpect.Single(gate.Snapshot()); + } + + [Scenario("Manual task gate state reports pending and all tasks")] + [Fact] + public void Manual_Task_Gate_State_Reports_Pending_And_All_Tasks() + { + var now = new DateTimeOffset(2026, 5, 30, 9, 0, 0, TimeSpan.Zero); + var gate = ManualTaskGate.Create("review-gate").WithClock(() => now).Build(); + + gate.Open("late", "Review late order"); + gate.Open("early", "Review early order"); + gate.Approve("late", "manager"); + + var state = gate.GetGateState(); + + ScenarioExpect.Equal("review-gate", state.GateName); + ScenarioExpect.True(state.IsBlocked); + ScenarioExpect.Equal(1, state.PendingCount); + ScenarioExpect.Equal("early", ScenarioExpect.Single(state.PendingTasks).Key); + ScenarioExpect.Equal(["early", "late"], state.AllTasks.Select(static task => task.Key).ToArray()); + } + + [Scenario("Manual task gate rejects invalid configuration")] + [Fact] + public void Manual_Task_Gate_Rejects_Invalid_Configuration() + { + var gate = ManualTaskGate.Create().Build(); + var record = new ManualTaskRecord("ORDER-1", "Review", null, null, ManualTaskStatus.Pending, DateTimeOffset.UtcNow, null, null, null); + + ScenarioExpect.Throws(() => ManualTaskGate.Create("").Build()); + ScenarioExpect.Throws(() => ManualTaskGate.Create().WithClock(null!)); + ScenarioExpect.Throws(() => ManualTaskGate.Create().WithKeyComparer(null!)); + ScenarioExpect.Throws(() => gate.Open("ORDER-1", "")); + ScenarioExpect.Throws(() => gate.Approve("ORDER-1", "")); + ScenarioExpect.Throws(() => new ManualTaskRecord("ORDER-1", "", null, null, ManualTaskStatus.Pending, DateTimeOffset.UtcNow, null, null, null)); + ScenarioExpect.Throws(() => new ManualTaskGateState("gate", false, 0, null!, [])); + ScenarioExpect.Throws(() => new ManualTaskGateState("gate", false, 0, [], null!)); + ScenarioExpect.True(record.IsBlocking); + ScenarioExpect.Null(gate.Reject("missing", "manager")); + } +}