From 3cbe5df5f22ef32c0270ce50364cd64474867c48 Mon Sep 17 00:00:00 2001 From: "Jerrett D. Davis" Date: Tue, 28 Oct 2025 21:30:01 -0500 Subject: [PATCH 1/2] feat(visitor): initial visitor implementation. --- README.md | 10 +- .../examples/api-exception-mapping-visitor.md | 99 ++++++++ docs/examples/event-processor-visitor.md | 76 +++++++ docs/examples/message-router-visitor.md | 65 ++++++ docs/examples/pos-visitor-routing.md | 126 +++++++++++ docs/examples/toc.yml | 12 + docs/index.md | 10 +- .../behavioral/visitor/actionvisitor.md | 72 ++++++ .../behavioral/visitor/alternatives.md | 72 ++++++ .../behavioral/visitor/asyncactionvisitor.md | 98 ++++++++ .../behavioral/visitor/asyncvisitor.md | 95 ++++++++ docs/patterns/behavioral/visitor/faq.md | 65 ++++++ docs/patterns/behavioral/visitor/guide.md | 40 ++++ .../behavioral/visitor/troubleshooting.md | 54 +++++ docs/patterns/behavioral/visitor/visitor.md | 211 ++++++++++++++++++ docs/patterns/toc.yml | 18 ++ docs/visitor-next-steps.md | 75 +++++++ .../Behavioral/State/StateMachine.cs | 2 +- .../Behavioral/Visitor/ActionVisitor.cs | 126 +++++++++++ .../Behavioral/Visitor/AsyncActionVisitor.cs | 123 ++++++++++ .../Behavioral/Visitor/AsyncVisitor.cs | 125 +++++++++++ .../Behavioral/Visitor/Visitor.cs | 135 +++++++++++ .../ApiGateway/MiniRouter.cs | 40 +++- .../VisitorDemo/VisitorDemo.cs | 146 ++++++++++++ src/PatternKit.Generators/packages.lock.json | 188 ++++++++++++++++ .../Behavioral/ActionVisitorExtraTests.cs | 65 ++++++ .../Behavioral/AsyncActionVisitorTests.cs | 134 +++++++++++ .../AsyncVisitorConcurrencyTests.cs | 66 ++++++ .../AsyncVisitorResultConcurrencyTests.cs | 69 ++++++ .../Behavioral/AsyncVisitorTests.cs | 118 ++++++++++ .../Behavioral/Iterator/AsyncFlowTests.cs | 2 +- .../Behavioral/Iterator/FlowTests.cs | 2 +- .../Behavioral/VisitorAdditionalTests.cs | 60 +++++ .../Behavioral/VisitorConcurrencyTests.cs | 66 ++++++ .../VisitorResultConcurrencyTests.cs | 69 ++++++ .../Behavioral/VisitorTests.cs | 73 ++++++ 36 files changed, 2792 insertions(+), 15 deletions(-) create mode 100644 docs/examples/api-exception-mapping-visitor.md create mode 100644 docs/examples/event-processor-visitor.md create mode 100644 docs/examples/message-router-visitor.md create mode 100644 docs/examples/pos-visitor-routing.md create mode 100644 docs/patterns/behavioral/visitor/actionvisitor.md create mode 100644 docs/patterns/behavioral/visitor/alternatives.md create mode 100644 docs/patterns/behavioral/visitor/asyncactionvisitor.md create mode 100644 docs/patterns/behavioral/visitor/asyncvisitor.md create mode 100644 docs/patterns/behavioral/visitor/faq.md create mode 100644 docs/patterns/behavioral/visitor/guide.md create mode 100644 docs/patterns/behavioral/visitor/troubleshooting.md create mode 100644 docs/patterns/behavioral/visitor/visitor.md create mode 100644 docs/visitor-next-steps.md create mode 100644 src/PatternKit.Core/Behavioral/Visitor/ActionVisitor.cs create mode 100644 src/PatternKit.Core/Behavioral/Visitor/AsyncActionVisitor.cs create mode 100644 src/PatternKit.Core/Behavioral/Visitor/AsyncVisitor.cs create mode 100644 src/PatternKit.Core/Behavioral/Visitor/Visitor.cs create mode 100644 src/PatternKit.Examples/VisitorDemo/VisitorDemo.cs create mode 100644 test/PatternKit.Tests/Behavioral/ActionVisitorExtraTests.cs create mode 100644 test/PatternKit.Tests/Behavioral/AsyncActionVisitorTests.cs create mode 100644 test/PatternKit.Tests/Behavioral/AsyncVisitorConcurrencyTests.cs create mode 100644 test/PatternKit.Tests/Behavioral/AsyncVisitorResultConcurrencyTests.cs create mode 100644 test/PatternKit.Tests/Behavioral/AsyncVisitorTests.cs create mode 100644 test/PatternKit.Tests/Behavioral/VisitorAdditionalTests.cs create mode 100644 test/PatternKit.Tests/Behavioral/VisitorConcurrencyTests.cs create mode 100644 test/PatternKit.Tests/Behavioral/VisitorResultConcurrencyTests.cs create mode 100644 test/PatternKit.Tests/Behavioral/VisitorTests.cs diff --git a/README.md b/README.md index f0b02c2..ae4cdef 100644 --- a/README.md +++ b/README.md @@ -446,8 +446,8 @@ var cachedRemoteProxy = Proxy.Create(id => remoteProxy.Execute(id)) --- ## 📚 Patterns Table -| Category | Patterns ✓ = implemented | -| -------------- |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **Creational** | [Factory](docs/patterns/creational/factory/factory.md) ✓ • [Composer](docs/patterns/creational/builder/composer.md) ✓ • [ChainBuilder](docs/patterns/creational/builder/chainbuilder.md) ✓ • [BranchBuilder](docs/patterns/creational/builder/chainbuilder.md) ✓ • [MutableBuilder](docs/patterns/creational/builder/mutablebuilder.md) ✓ • [Prototype](docs/patterns/creational/prototype/prototype.md) ✓ • [Singleton](docs/patterns/creational/singleton/singleton.md) ✓ | -| **Structural** | [Adapter](docs/patterns/structural/adapter/fluent-adapter.md) ✓ • [Bridge](docs/patterns/structural/bridge/bridge.md) ✓ • [Composite](docs/patterns/structural/composite/composite.md) ✓ • [Decorator](docs/patterns/structural/decorator/decorator.md) ✓ • [Facade](docs/patterns/structural/facade/facade.md) ✓ • [Flyweight](docs/patterns/structural/flyweight/index.md) ✓ • [Proxy](docs/patterns/structural/proxy/index.md) ✓ | -| **Behavioral** | [Strategy](docs/patterns/behavioral/strategy/strategy.md) ✓ • [TryStrategy](docs/patterns/behavioral/strategy/trystrategy.md) ✓ • [ActionStrategy](docs/patterns/behavioral/strategy/actionstrategy.md) ✓ • [ActionChain](docs/patterns/behavioral/chain/actionchain.md) ✓ • [ResultChain](docs/patterns/behavioral/chain/resultchain.md) ✓ • [ReplayableSequence](docs/patterns/behavioral/iterator/replayablesequence.md) ✓ • [WindowSequence](docs/patterns/behavioral/iterator/windowsequence.md) ✓ • [Command](docs/patterns/behavioral/command/command.md) ✓ • [Mediator](docs/patterns/behavioral/mediator/mediator.md) ✓ • [Memento](docs/patterns/behavioral/memento/memento.md) ✓ • [Observer](docs/patterns/behavioral/observer/observer.md) ✓ • [State](docs/patterns/behavioral/state/state.md) ✓ • Template Method (planned) • Visitor (planned) | +| Category | Patterns | +| -------------- |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Creational** | [Factory](docs/patterns/creational/factory/factory.md) • [Composer](docs/patterns/creational/builder/composer.md) • [ChainBuilder](docs/patterns/creational/builder/chainbuilder.md) • [BranchBuilder](docs/patterns/creational/builder/chainbuilder.md) • [MutableBuilder](docs/patterns/creational/builder/mutablebuilder.md) • [Prototype](docs/patterns/creational/prototype/prototype.md) • [Singleton](docs/patterns/creational/singleton/singleton.md) | +| **Structural** | [Adapter](docs/patterns/structural/adapter/fluent-adapter.md) • [Bridge](docs/patterns/structural/bridge/bridge.md) • [Composite](docs/patterns/structural/composite/composite.md) • [Decorator](docs/patterns/structural/decorator/decorator.md) • [Facade](docs/patterns/structural/facade/facade.md) • [Flyweight](docs/patterns/structural/flyweight/index.md) • [Proxy](docs/patterns/structural/proxy/index.md) | +| **Behavioral** | [Strategy](docs/patterns/behavioral/strategy/strategy.md) • [TryStrategy](docs/patterns/behavioral/strategy/trystrategy.md) • [ActionStrategy](docs/patterns/behavioral/strategy/actionstrategy.md) • [ActionChain](docs/patterns/behavioral/chain/actionchain.md) • [ResultChain](docs/patterns/behavioral/chain/resultchain.md) • [ReplayableSequence](docs/patterns/behavioral/iterator/replayablesequence.md) • [WindowSequence](docs/patterns/behavioral/iterator/windowsequence.md) • [Command](docs/patterns/behavioral/command/command.md) • [Mediator](docs/patterns/behavioral/mediator/mediator.md) • [Memento](docs/patterns/behavioral/memento/memento.md) • [Observer](docs/patterns/behavioral/observer/observer.md) • [State](docs/patterns/behavioral/state/state.md) • [Template Method](docs/patterns/behavioral/template/template.md) • [Visitor](docs/patterns/behavioral/visitor/visitor.md) | diff --git a/docs/examples/api-exception-mapping-visitor.md b/docs/examples/api-exception-mapping-visitor.md new file mode 100644 index 0000000..e20cf0c --- /dev/null +++ b/docs/examples/api-exception-mapping-visitor.md @@ -0,0 +1,99 @@ +# Visitor — API Exception Mapping (ASP.NET Core) + +Map exceptions to HTTP responses with a result visitor. Centralize error policy without scattering `catch` blocks across controllers. + +--- + +## Goal + +- Convert exceptions to `ProblemDetails` or `IResult`. +- Keep mappings in one place; add specialized cases without touching controllers. +- Share a single, immutable visitor via DI. + +--- + +## Exception Types + +```csharp +public sealed class NotFoundException(string resource, string id) : Exception($"{resource} '{id}' not found"); +public sealed class ValidationException(IDictionary errors) : Exception("Validation failed"); +public sealed class ForbiddenException(string? reason = null) : Exception(reason ?? "Forbidden"); +``` + +--- + +## Visitor Registration + +```csharp +// Program.cs or composition root +builder.Services.AddSingleton>(_ => + Visitor + .Create() + .On(ex => Results.Problem( + statusCode: StatusCodes.Status404NotFound, + title: "Not Found", + detail: ex.Message)) + .On(ex => Results.ValidationProblem( + errors: ex.Errors.ToDictionary(kv => kv.Key, kv => kv.Value))) + .On(ex => Results.Problem( + statusCode: StatusCodes.Status403Forbidden, + title: "Forbidden", + detail: ex.Message)) + .Default(ex => Results.Problem( + statusCode: StatusCodes.Status500InternalServerError, + title: "Server Error", + detail: builder.Environment.IsDevelopment() ? ex.ToString() : ex.Message)) + .Build()); +``` + +--- + +## Middleware + +```csharp +public sealed class ExceptionMappingMiddleware( + RequestDelegate next, + Visitor mapper, + ILogger log) +{ + public async Task Invoke(HttpContext ctx) + { + try + { + await next(ctx); + } + catch (Exception ex) + { + log.LogError(ex, "Unhandled exception"); + var result = mapper.Visit(ex); + await result.ExecuteAsync(ctx); + } + } +} + +// Program.cs +app.UseMiddleware(); +``` + +--- + +## Why This Works Well + +- The mapping is explicit and ordered; specific exceptions come first. +- A default keeps APIs resilient to unknown errors. +- The visitor instance is immutable and safe to reuse across requests. + +--- + +## Tests (sketch) + +```csharp +[Fact] +public void Maps_NotFound_To_404() +{ + var v = BuildMapper(); + var res = v.Visit(new NotFoundException("Order", "123")); + // Assert res is Problem with 404... +} +``` + diff --git a/docs/examples/event-processor-visitor.md b/docs/examples/event-processor-visitor.md new file mode 100644 index 0000000..85b42d7 --- /dev/null +++ b/docs/examples/event-processor-visitor.md @@ -0,0 +1,76 @@ +# Visitor — Event Processing (Orchestration) + +Use action and async visitors to route domain events to handlers. Keep orchestration logic discoverable and easy to extend. + +--- + +## Domain Events + +```csharp +abstract record Event(DateTimeOffset At); +record OrderPlaced(string OrderId, decimal Total, DateTimeOffset At) : Event(At); +record PaymentCaptured(string OrderId, string TxId, DateTimeOffset At) : Event(At); +record ShipmentScheduled(string OrderId, string Tracking, DateTimeOffset At) : Event(At); +record AuditLog(string Message, DateTimeOffset At) : Event(At); +``` + +--- + +## Async Orchestrator + +```csharp +public interface IEmail { Task SendAsync(string to, string subject, CancellationToken ct); } +public interface IAccounting { Task RecordAsync(string orderId, decimal total, CancellationToken ct); } +public interface IShipping { Task ScheduleAsync(string orderId, string tracking, CancellationToken ct); } + +public static class EventOrchestrator +{ + public static AsyncActionVisitor Build(IEmail email, IAccounting acct, IShipping ship) + => AsyncActionVisitor + .Create() + .On(async (e, ct) => + { + await acct.RecordAsync(e.OrderId, e.Total, ct); + await email.SendAsync("ops@example.com", $"Order {e.OrderId} placed", ct); + }) + .On(async (e, ct) => + await email.SendAsync("ops@example.com", $"Payment captured {e.TxId}", ct)) + .On(async (e, ct) => + await ship.ScheduleAsync(e.OrderId, e.Tracking, ct)) + .Default((e, _) => { /* ignore or metric */ return default; }) + .Build(); +} +``` + +Usage +```csharp +var orchestrator = EventOrchestrator.Build(email, accounting, shipping); +foreach (var e in events) await orchestrator.VisitAsync(e, ct); +``` + +--- + +## Synchronous Projection (Result Visitor) + +```csharp +public static class EventProjection +{ + public static Visitor BuildSummary() + => Visitor + .Create() + .On(e => $"Placed:{e.OrderId}:{e.Total:C}") + .On(e => $"Paid:{e.OrderId}:{e.TxId}") + .On(e => $"Ship:{e.OrderId}:{e.Tracking}") + .Default(e => $"Audit:{e.At:O}") + .Build(); +} +``` + +--- + +## Testing Tips + +- Cover each event type and the default path. +- Assert side effects by using test doubles or counters. +- Include cancellation tests to ensure `ct` is respected. + diff --git a/docs/examples/message-router-visitor.md b/docs/examples/message-router-visitor.md new file mode 100644 index 0000000..4992e64 --- /dev/null +++ b/docs/examples/message-router-visitor.md @@ -0,0 +1,65 @@ +# Visitor — Message Router (Background Worker) + +Dispatch messages pulled from a queue to processors keyed by runtime type. Scale out by sharing a single immutable visitor across workers. + +--- + +## Messages + +```csharp +abstract record Message(string Id); +record UserCreated(string Id, string Email) : Message(Id); +record OrderSubmitted(string Id, string OrderId) : Message(Id); +record InventoryLow(string Id, string Sku, int Qty) : Message(Id); +``` + +--- + +## Router (Async Action Visitor) + +```csharp +public interface IMessageHandlers +{ + Task On(UserCreated m, CancellationToken ct); + Task On(OrderSubmitted m, CancellationToken ct); + Task On(InventoryLow m, CancellationToken ct); + Task OnUnknown(Message m, CancellationToken ct); +} + +public static class MessageRouter +{ + public static AsyncActionVisitor Build(IMessageHandlers h) + => AsyncActionVisitor + .Create() + .On(h.On) + .On(h.On) + .On(h.On) + .Default(h.OnUnknown) + .Build(); +} +``` + +--- + +## Worker Loop + +```csharp +public sealed class Worker(AsyncActionVisitor router, ILogger log) +{ + public async Task RunAsync(IAsyncEnumerable stream, CancellationToken ct) + { + await foreach (var m in stream.WithCancellation(ct)) + { + try { await router.VisitAsync(m, ct); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { } + catch (Exception ex) { log.LogError(ex, "Message failed: {Id}", m.Id); } + } + } +} +``` + +Operational notes +- Handlers receive a `CancellationToken` for graceful shutdown. +- Add metrics/logging to the default branch to analyze unknown message types. +- For high volume, prefer minimal allocations in handlers. + diff --git a/docs/examples/pos-visitor-routing.md b/docs/examples/pos-visitor-routing.md new file mode 100644 index 0000000..31b99a1 --- /dev/null +++ b/docs/examples/pos-visitor-routing.md @@ -0,0 +1,126 @@ +# Visitor — POS Tender Routing & Receipt Rendering + +This example demonstrates using Visitors to route payment tenders to handlers and to render receipt lines. It mirrors common POS/integration needs: dispatch by tender type for processing and projection to a printable receipt. + +--- + +## The Domain + +```csharp +abstract record Tender(decimal Amount); +record Cash(decimal Value) : Tender(Value); +record Card(string Brand, string Last4, decimal Value) : Tender(Value); +record GiftCard(string Code, decimal Value) : Tender(Value); +record StoreCredit(string CustomerId, decimal Value) : Tender(Value); +``` + +Full example source: `src/PatternKit.Examples/VisitorDemo/VisitorDemo.cs:1` + +--- + +## Rendering (Result Visitor) + +Goal: Turn tenders into receipt lines. + +```csharp +public static class ReceiptRendering +{ + public static Visitor CreateRenderer() => Visitor + .Create() + .On(t => $"Cash {t.Value,8:C}") + .On(t => $"{t.Brand} ****{t.Last4,4} {t.Value,8:C}") + .On(t => $"GiftCard {t.Code,-8} {t.Value,8:C}") + .On(t => $"StoreCredit {t.CustomerId,-6} {t.Value,8:C}") + .Default(t => $"Other {t.Amount,8:C}") + .Build(); +} +``` + +Usage +```csharp +var renderer = ReceiptRendering.CreateRenderer(); +var lines = tenders.Select(t => renderer.Visit(t)).ToArray(); +``` + +--- + +## Routing (Action Visitor) + +Goal: Route tenders to a handler interface and keep counts. + +```csharp +public interface ITenderHandler +{ + void Cash(Cash t); + void Card(Card t); + void Gift(GiftCard t); + void Credit(StoreCredit t); + void Fallback(Tender t); +} + +public sealed class CountersHandler : ITenderHandler +{ + public int CashCount, CardCount, GiftCount, CreditCount, FallbackCount; + public decimal Total; + public void Cash(Cash t) { CashCount++; Total += t.Value; } + public void Card(Card t) { CardCount++; Total += t.Value; } + public void Gift(GiftCard t) { GiftCount++; Total += t.Value; } + public void Credit(StoreCredit t) { CreditCount++; Total += t.Value; } + public void Fallback(Tender t) { FallbackCount++; Total += t.Amount; } +} + +public static class Routing +{ + public static ActionVisitor CreateRouter(ITenderHandler handler) => ActionVisitor + .Create() + .On(handler.Cash) + .On(handler.Card) + .On(handler.Gift) + .On(handler.Credit) + .Default(handler.Fallback) + .Build(); +} +``` + +Usage +```csharp +var counters = new CountersHandler(); +var router = Routing.CreateRouter(counters); +foreach (var t in tenders) router.Visit(t); +``` + +--- + +## End‑to‑End Demo + +```csharp +var tenders = new Tender[] +{ + new Cash(10.00m), + new Card("VISA", "4242", 15.75m), + new GiftCard("GFT-001", 5.00m), + new StoreCredit("C123", 3.25m), +}; + +var renderer = ReceiptRendering.CreateRenderer(); +var counters = new CountersHandler(); +var router = Routing.CreateRouter(counters); + +foreach (var t in tenders) + router.Visit(t); + +var lines = tenders.Select(t => renderer.Visit(t)).ToArray(); +``` + +--- + +## Why Visitor Here + +- Clean separation of types and operations (routes, receipts). +- First‑match‑wins mapping keeps specific types first. +- Built visitors are immutable and thread‑safe. + +Operational notes +- Keep visitors as application singletons; rebuild only when behavior changes. +- For multi‑tenant rules, compose visitors per tenant from shared primitives. +- Add a default to avoid runtime errors on unknown tender types; log and continue. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 8b96b52..a7c47b7 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -28,6 +28,18 @@ - name: Pricing Calculator (Async Sources, Loyalty, Rounding) href: pricing-calculator.md +- name: Visitor — POS Tender Routing & Receipt Rendering + href: pos-visitor-routing.md + +- name: Visitor — API Exception Mapping (ASP.NET Core) + href: api-exception-mapping-visitor.md + +- name: Visitor — Event Processing (Orchestration) + href: event-processor-visitor.md + +- name: Visitor — Message Router (Background Worker) + href: message-router-visitor.md + - name: Proxy Pattern Demonstrations — Virtual, Protection, Caching, Logging, Mocking, Remote href: proxy-demo.md diff --git a/docs/index.md b/docs/index.md index fbd5d81..a5480f7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -62,11 +62,11 @@ if (parser.Execute("123", out var value)) PatternKit will grow to cover **Creational**, **Structural**, and **Behavioral** patterns with fluent, discoverable APIs: -| Category | Patterns ✓ = implemented | -| -------------- |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **Creational** | [Factory](patterns/creational/factory/factory.md) ✓ • [Composer](patterns/creational/builder/composer.md) ✓ • [ChainBuilder](patterns/creational/builder/chainbuilder.md) ✓ • [BranchBuilder](patterns/creational/builder/chainbuilder.md) ✓ • [MutableBuilder](patterns/creational/builder/mutablebuilder.md) ✓ • [Prototype](patterns/creational/prototype/prototype.md) ✓ • [Singleton](patterns/creational/singleton/singleton.md) ✓ | -| **Structural** | [Adapter](patterns/structural/adapter/fluent-adapter.md) ✓ • [Bridge](patterns/structural/bridge/bridge.md) ✓ • [Composite](patterns/structural/composite/composite.md) ✓ • [Decorator](patterns/structural/decorator/index.md) ✓ • [Facade](patterns/structural/facade/facade.md) ✓ • [Flyweight](patterns/structural/flyweight/index.md) ✓ • [Proxy](patterns/structural/proxy/index.md) ✓ | -| **Behavioral** | [Strategy](patterns/behavioral/strategy/strategy.md) ✓ • [TryStrategy](patterns/behavioral/strategy/trystrategy.md) ✓ • [ActionStrategy](patterns/behavioral/strategy/actionstrategy.md) ✓ • [ActionChain](patterns/behavioral/chain/actionchain.md) ✓ • [ResultChain](patterns/behavioral/chain/resultchain.md) ✓ • [Command](patterns/behavioral/command/command.md) ✓ • [ReplayableSequence](patterns/behavioral/iterator/replayablesequence.md) ✓ • [WindowSequence](patterns/behavioral/iterator/windowsequence.md) ✓ • [Mediator](patterns/behavioral/mediator/mediator.md) ✓ • [Memento](patterns/behavioral/memento/memento.md) ✓ • [Observer](patterns/behavioral/observer/observer.md) ✓ • [AsyncObserver](patterns/behavioral/observer/asyncobserver.md) ✓ • State (planned) • Template Method (planned) • Visitor (planned) | +| Category | Patterns | +| -------------- |-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Creational** | [Factory](patterns/creational/factory/factory.md) • [Composer](patterns/creational/builder/composer.md) • [ChainBuilder](patterns/creational/builder/chainbuilder.md) • [BranchBuilder](patterns/creational/builder/chainbuilder.md) • [MutableBuilder](patterns/creational/builder/mutablebuilder.md) • [Prototype](patterns/creational/prototype/prototype.md) • [Singleton](patterns/creational/singleton/singleton.md) | +| **Structural** | [Adapter](patterns/structural/adapter/fluent-adapter.md) • [Bridge](patterns/structural/bridge/bridge.md) • [Composite](patterns/structural/composite/composite.md) • [Decorator](patterns/structural/decorator/index.md) • [Facade](patterns/structural/facade/facade.md) • [Flyweight](patterns/structural/flyweight/index.md) • [Proxy](patterns/structural/proxy/index.md) | +| **Behavioral** | [Strategy](patterns/behavioral/strategy/strategy.md) • [TryStrategy](patterns/behavioral/strategy/trystrategy.md) • [ActionStrategy](patterns/behavioral/strategy/actionstrategy.md) • [ActionChain](patterns/behavioral/chain/actionchain.md) • [ResultChain](patterns/behavioral/chain/resultchain.md) • [Command](patterns/behavioral/command/command.md) • [ReplayableSequence](patterns/behavioral/iterator/replayablesequence.md) • [WindowSequence](patterns/behavioral/iterator/windowsequence.md) • [Mediator](patterns/behavioral/mediator/mediator.md) • [Memento](patterns/behavioral/memento/memento.md) • [Observer](patterns/behavioral/observer/observer.md) • [AsyncObserver](patterns/behavioral/observer/asyncobserver.md) • [Visitor](patterns/behavioral/visitor/visitor.md) • [State](patterns/behavioral/state/state.md) • [Template Method](patterns/behavioral/template/template.md) | Each pattern will ship with: diff --git a/docs/patterns/behavioral/visitor/actionvisitor.md b/docs/patterns/behavioral/visitor/actionvisitor.md new file mode 100644 index 0000000..70dae43 --- /dev/null +++ b/docs/patterns/behavioral/visitor/actionvisitor.md @@ -0,0 +1,72 @@ +# ActionVisitor + +Side‑effecting visitor that maps runtime types to actions, evaluated with first‑match‑wins semantics. + +--- + +## What It Is + +- Type‑to‑action mapping: `On(Action)` with optional `.Default(...)`. +- First matching action executes; others are skipped. +- Non‑throwing variant: `TryVisit(in TBase)` returns `false` when no action/default matched. + +--- + +## TL;DR Example + +```csharp +var v = ActionVisitor + .Create() + .On(t => CashHandler(t)) + .On(t => CardHandler(t)) + .Default(t => LogUnhandled(t)) + .Build(); + +v.Visit(tender); // throws if no match and no default +``` + +See `src/PatternKit.Examples/VisitorDemo/VisitorDemo.cs:48` for a complete routing demo. + +--- + +## API + +- `void Visit(in TBase node)` — runs first matching action or the default; throws if neither applies. +- `bool TryVisit(in TBase node)` — returns `false` when nothing matches and no default exists. +- `Builder.On(Action)` — registers a type‑specific action; evaluation order is registration order. +- `Builder.Default(ActionHandler)` — registers a fallback for unknown types. + +--- + +## Operational Guidance + +- Prefer idempotent and fast actions; guard side effects (I/O, retries) at the edges. +- Put the most specific types first to avoid base‑type shadowing. +- Share built visitors as singletons; builders are not thread‑safe. +- Use `.Default(...)` to log and continue on unknown types in production. + +--- + +## Testing + +Reference tests: `test/PatternKit.Tests/Behavioral/VisitorTests.cs:49` + +```csharp +var counters = new int[3]; +var v = ActionVisitor.Create() + .On(_ => Interlocked.Increment(ref counters[0])) + .On(_ => Interlocked.Increment(ref counters[1])) + .Default(_ => Interlocked.Increment(ref counters[2])) + .Build(); + +v.Visit(new Add(...)); +v.Visit(new Number(...)); +v.Visit(new Neg(...)); // default +``` + +--- + +## See Also + +- `Visitor` — result‑producing variant +- `AsyncActionVisitor` — asynchronous actions diff --git a/docs/patterns/behavioral/visitor/alternatives.md b/docs/patterns/behavioral/visitor/alternatives.md new file mode 100644 index 0000000..1be942f --- /dev/null +++ b/docs/patterns/behavioral/visitor/alternatives.md @@ -0,0 +1,72 @@ +# Visitor — Alternatives & Comparisons + +This page compares PatternKit’s fluent Visitor with common alternatives in C# and when to choose each. + +--- + +## C# Pattern Matching (`switch` / `is`) + +Pros +- Built‑in and terse for one‑off decisions +- Exhaustiveness checks in some cases + +Cons +- Hard to centralize and reuse across the codebase +- Grows unwieldy as operations multiply; logic scatters + +Choose pattern matching when the branching is local and not reused. + +--- + +## Virtual Methods / Classic OO Polymorphism + +Pros +- Behavior lives with the type; easy to find +- No external registry + +Cons +- You must modify types for every new operation +- Can bloat domain types with unrelated concerns + +Choose virtual methods when the behavior is core to the type and changes with it. + +--- + +## Classic GoF Visitor (Double‑Dispatch) + +Pros +- Compile‑time safety via `Accept(Visitor)` across all nodes + +Cons +- Intrusive: every type must implement `Accept` +- Adding a new subtype requires touching the visitor interface and all implementations + +PatternKit’s visitors are non‑intrusive and fluent: no changes to your domain types. + +--- + +## Dictionaries of `Type` → Delegate + +Pros +- Simple mapping for small cases + +Cons +- Manual type casting; error‑prone +- No ordering / specificity control (base vs derived) +- No first‑match / default semantics built‑in + +PatternKit provides ordering, default handling, and strong typing out of the box. + +--- + +## Strategy Pattern + +Pros +- Predicate‑based dispatch, not type‑based +- Great for content negotiation, rule packs + +Cons +- Not keyed by runtime type + +Use `Strategy` for predicate logic; use `Visitor` for runtime type dispatch. + diff --git a/docs/patterns/behavioral/visitor/asyncactionvisitor.md b/docs/patterns/behavioral/visitor/asyncactionvisitor.md new file mode 100644 index 0000000..de2ca85 --- /dev/null +++ b/docs/patterns/behavioral/visitor/asyncactionvisitor.md @@ -0,0 +1,98 @@ +# AsyncActionVisitor + +Asynchronous, side‑effecting visitor that maps runtime types to `ValueTask`‑returning actions. Ideal for I/O‑bound work keyed by type (e.g., routing, enrichment, persistence). + +--- + +## What It Is + +- Type‑to‑async‑action mapping: `On(Func)` with optional `.Default(...)`. +- First‑match‑wins evaluation. Put specific types before base types. +- Non‑throwing variant: `TryVisitAsync(node, ct)` returns `false` when no action/default matched. + +--- + +## TL;DR Example + +```csharp +var v = AsyncActionVisitor + .Create() + .On(async (t, ct) => await ledger.ApplyCashAsync(t, ct)) + .On(async (t, ct) => await gateway.ChargeAsync(t, ct)) + .Default(async (t, ct) => await logger.LogAsync($"Unknown: {t}", ct)) + .Build(); + +await v.VisitAsync(tender, ct); // throws if no match and no default +``` + +See core API: `src/PatternKit.Core/Behavioral/Visitor/AsyncActionVisitor.cs:6`. + +--- + +## API + +- `ValueTask VisitAsync(TBase node, CancellationToken ct)` — runs first matching action or default; throws when neither applies. +- `ValueTask TryVisitAsync(TBase node, CancellationToken ct)` — non‑throwing path. +- `Builder.On(Func)` — registers a type‑specific async action. +- `Builder.On(Action)` — sync adapter. +- `Builder.Default(ActionHandler)` or `Builder.Default(Action)` — fallback action. + +--- + +## Composition & DI + +Register built visitors as singletons; capture dependencies via closures or provide a factory. + +```csharp +// Registration +services.AddSingleton>(sp => +{ + var ledger = sp.GetRequiredService(); + var gateway = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + + return AsyncActionVisitor.Create() + .On((t, ct) => ledger.ApplyCashAsync(t, ct)) + .On((t, ct) => gateway.ChargeAsync(t, ct)) + .Default((t, ct) => { logger.LogWarning("Unknown tender {Amount}", t.Amount); return default; }) + .Build(); +}); + +// Usage +public sealed class Router(AsyncActionVisitor visitor) +{ + public ValueTask RouteAsync(Tender t, CancellationToken ct) => visitor.VisitAsync(t, ct); +} +``` + +Notes +- Built visitors are immutable and thread‑safe; builders are not thread‑safe. +- For multi‑tenant rules, build per‑tenant visitors at composition time and select by tenant ID. + +--- + +## Testing + +Pattern: parallelize a mixed set of nodes and assert side‑effect counters. + +```csharp +var counters = new int[3]; // add, number, default +var v = AsyncActionVisitor.Create() + .On((_, _) => { Interlocked.Increment(ref counters[0]); return default; }) + .On((_, _) => { Interlocked.Increment(ref counters[1]); return default; }) + .Default((_, _) => { Interlocked.Increment(ref counters[2]); return default; }) + .Build(); + +await v.VisitAsync(new Add(...)); +``` + +Reference tests: `test/PatternKit.Tests/Behavioral/AsyncVisitorTests.cs:69`. + +--- + +## See Also + +- `AsyncVisitor` — async result variant +- `ActionVisitor` — synchronous actions +- Troubleshooting — `docs/patterns/behavioral/visitor/troubleshooting.md:1` + - Examples: `docs/examples/message-router-visitor.md:1`, `docs/examples/event-processor-visitor.md:1` diff --git a/docs/patterns/behavioral/visitor/asyncvisitor.md b/docs/patterns/behavioral/visitor/asyncvisitor.md new file mode 100644 index 0000000..77b16e6 --- /dev/null +++ b/docs/patterns/behavioral/visitor/asyncvisitor.md @@ -0,0 +1,95 @@ +# AsyncVisitor + +Asynchronous visitors mirror the fluent API and use `ValueTask` + `CancellationToken`. They’re ideal for I/O‑bound operations (DB calls, HTTP) keyed by runtime type. + +--- + +## TL;DR Example (Async Result) + +```csharp +var v = AsyncVisitor + .Create() + .On((n, _) => new ValueTask($"#{n.Value}")) + .Default((_, _) => new ValueTask("?")) + .Build(); + +var (ok, res) = await v.TryVisitAsync(new Number(7)); +``` + +Async action variant: `AsyncActionVisitor`. + +--- + +## APIs + +- `ValueTask VisitAsync(TBase node, CancellationToken ct)` — runs first matching handler or default; throws on no‑match without default. +- `ValueTask<(bool ok, TResult result)> TryVisitAsync(TBase node, CancellationToken ct)` — non‑throwing path. +- `AsyncActionVisitor` provides `ValueTask VisitAsync(...)` and `ValueTask TryVisitAsync(...)`. + +Builder registration +- `.On(Func> handler)` +- `.On(Func handler)` — sync adapter +- `.On(TResult constant)` +- `.Default(Handler)` or `.Default(Func)` (sync adapter) + +--- + +## Cancellation And Responsiveness + +- Always pass the `CancellationToken` down to async handlers. +- Throw early on cancellation to avoid wasted work. +- Avoid blocking or long‑running sync work in async handlers to keep threads available. + +See cancellation test: `test/PatternKit.Tests/Behavioral/AsyncVisitorTests.cs:41`. + +--- + +## Performance + +- `ValueTask` avoids extra allocations for already‑completed operations. +- Dispatch remains a simple indexed loop over arrays of predicates/handlers. +- Built instances are immutable and safe to share across requests. + +--- + +## Composition & DI + +Register built visitors as singletons; capture dependencies in closures. Prefer passing `CancellationToken` down. + +```csharp +// Registration +services.AddSingleton>(sp => +{ + var brandSvc = sp.GetRequiredService(); + return AsyncVisitor + .Create() + .On(async (t, ct) => $"{await brandSvc.ResolveAsync(t.Brand, ct)} ****{t.Last4}") + .On((t, _) => new ValueTask($"Cash {t.Value:C}")) + .Default((t, _) => new ValueTask($"Other {t.Amount:C}")) + .Build(); +}); + +// Usage +public sealed class ReceiptService(AsyncVisitor renderer) +{ + public ValueTask LineForAsync(Tender t, CancellationToken ct) => renderer.VisitAsync(t, ct); +} +``` + +Notes +- Built visitors are immutable; builders are not thread‑safe. +- Use sync adapters for quick prototyping; move to async handlers for I/O. + +--- + +## Example Tests + +`test/PatternKit.Tests/Behavioral/AsyncVisitorTests.cs:16` covers async result visitor dispatch and try semantics. The async action variant is covered at `test/PatternKit.Tests/Behavioral/AsyncVisitorTests.cs:69`. + +--- + +## See Also + +- `Visitor` — synchronous result variant +- `AsyncActionVisitor` — asynchronous actions + - Examples: `docs/examples/event-processor-visitor.md:1`, `docs/examples/message-router-visitor.md:1`, `docs/examples/api-exception-mapping-visitor.md:1` diff --git a/docs/patterns/behavioral/visitor/faq.md b/docs/patterns/behavioral/visitor/faq.md new file mode 100644 index 0000000..2a5f9fe --- /dev/null +++ b/docs/patterns/behavioral/visitor/faq.md @@ -0,0 +1,65 @@ +# Visitor — FAQ + +Common questions about using the fluent Visitor APIs in PatternKit. + +--- + +## What’s the difference between `Visitor` and `ActionVisitor`? + +- `Visitor` returns a value from handlers. +- `ActionVisitor` only performs side effects; no return value. + +--- + +## How do I avoid exceptions when no type matches? + +Either add `.Default(...)` or call `TryVisit(...)` (`TryVisitAsync(...)`) which returns `false` instead of throwing. + +--- + +## Does registration order matter? + +Yes. The first matching registration wins. Put more specific types before base types. + +--- + +## Are visitors thread‑safe? + +Built visitors are immutable and thread‑safe. Builders are not thread‑safe. Ensure your handlers’ dependencies are safe for concurrent use. + +--- + +## Can I compose visitors? + +Yes. Compose per module (e.g., Billing, Catalog), then combine at an edge (router, renderer). This keeps chains short and local. + +--- + +## Can I mix sync and async handlers? + +Use `AsyncVisitor`/`AsyncActionVisitor` for async; they support sync adapters (`.On(Func)` wraps to `ValueTask`). + +--- + +## How do I test visitors? + +Unit‑test match ordering, default behavior, and negative cases (unknown types). See `test/PatternKit.Tests/Behavioral/VisitorTests.cs:16` and `test/PatternKit.Tests/Behavioral/AsyncVisitorTests.cs:16`. + +--- + +## Is this the same as classic GoF Visitor? + +Conceptually yes (separate operations from types) but implemented non‑intrusively: you don’t modify domain types or implement `Accept(...)`. + +--- + +## What about performance? + +Dispatch is O(N) over registrations using a tight `for` loop. Keep frequent matches early and shard very large hierarchies if needed. + +--- + +## Can I handle open generics? + +Visitors work with concrete runtime types. For open generic families, register the concrete constructed types at composition time. + diff --git a/docs/patterns/behavioral/visitor/guide.md b/docs/patterns/behavioral/visitor/guide.md new file mode 100644 index 0000000..41251f1 --- /dev/null +++ b/docs/patterns/behavioral/visitor/guide.md @@ -0,0 +1,40 @@ +# Visitor — Enterprise Guide + +This guide provides practical guidance for adopting Visitor in larger codebases: architecture, performance, testing, and operations. + +## Architecture & Organization +- Centralize composition: create dedicated static factories (e.g., `ReceiptRendering.CreateRenderer()`). +- Favor composition over mega-visitors: split by module (Billing, Catalog, POS) and compose at the edge. +- Keep handlers small; delegate complex logic to services. + +## Error Handling & Defaults +- Always add a `Default(...)` for resilience and observability. Log unknown types and continue. +- Result visitors: prefer returning error objects vs throwing in handlers; reserve throws for exceptional conditions. +- Action visitors: aim for idempotency; guard external side effects. + +## Performance +- Registration order matters; put frequent types first. +- Avoid per-call allocations in handlers. Cache dependencies; reuse buffers. +- For very large hierarchies, consider pre-sharding by “family” or using multiple visitors. + +## Concurrency & Thread Safety +- Built visitors are immutable and thread-safe. Register once, share many. +- Ensure downstream services are thread-safe or scoped appropriately (e.g., per-request). + +## Testing Strategy +- BDD tests per visitor: cover match, default, and ordering behavior. +- Add negative tests (unknown type) and concurrency smoke tests for action visitors. +- Use example-driven tests to document behavior to new team members. + +## Migration Tips +- Replace `switch`/`if` chains with `On` registrations incrementally. +- Start with a thin visitor over existing logic; move logic into focused handlers gradually. +- Keep old code behind feature toggles while validating behavior parity. + +## Security & Compliance +- Treat handlers as policy-enforcement points (authorization, validation) when applicable. +- Log minimally necessary PII; pass security context via handler closures rather than globals. + +## Operations +- Expose versioning: recompose visitors per release; keep factories discoverable. +- Document defaults clearly: what is logged, when it triggers, how to extend. diff --git a/docs/patterns/behavioral/visitor/troubleshooting.md b/docs/patterns/behavioral/visitor/troubleshooting.md new file mode 100644 index 0000000..51a5132 --- /dev/null +++ b/docs/patterns/behavioral/visitor/troubleshooting.md @@ -0,0 +1,54 @@ +# Visitor — Troubleshooting + +Tips for common issues when using Visitors. + +--- + +## `InvalidOperationException: No strategy matched` + +Cause +- No registration matched and no `.Default(...)` set. + +Fix +- Add `.Default(...)` for resilience, or use `TryVisit(...)` and branch on the boolean result. + +--- + +## Handler never runs (shadowed by base type) + +Cause +- A base type registration appears before a more specific subtype. + +Fix +- Move the specific `On` registration before the base `On`. + +--- + +## Concurrent usage issues + +Cause +- Sharing the builder across threads; or handlers depend on non‑thread‑safe services. + +Fix +- Build once and share the built instance. Ensure handler dependencies are thread‑safe or scoped properly. + +--- + +## Unexpected allocations + +Cause +- Capturing large closures; using boxing inside handlers; returning large new objects per call. + +Fix +- Keep handlers lean, avoid boxing/value‑type to object conversions, reuse buffers where appropriate. + +--- + +## Async deadlocks / responsiveness issues + +Cause +- Blocking in async handlers, not honoring `CancellationToken`. + +Fix +- Make handlers truly async, pass `ct`, and bail early on cancellation. + diff --git a/docs/patterns/behavioral/visitor/visitor.md b/docs/patterns/behavioral/visitor/visitor.md new file mode 100644 index 0000000..ba66bc7 --- /dev/null +++ b/docs/patterns/behavioral/visitor/visitor.md @@ -0,0 +1,211 @@ +# Visitor (Fluent) + +Visitor separates operations from the objects they operate on. PatternKit provides fluent, type‑safe visitors that dispatch by runtime type and either return a value (result visitor) or perform side effects (action visitor). Use it to add operations like formatting, routing, validation, and projection without modifying your model types. + +--- + +## What It Is + +- Type‑based dispatch using a fluent builder: `On(...)` with optional `.Default(...)`. +- First‑match‑wins evaluation. Put specific types before base types. +- Immutable and thread‑safe after `Build()`; the builder itself is not thread‑safe. +- Non‑intrusive: your domain types do not need to implement `Accept(...)` (no classic double‑dispatch required). + +> Variants: Result `Visitor`, side‑effecting `ActionVisitor`, and async `AsyncVisitor`/`AsyncActionVisitor` using `ValueTask` + `CancellationToken`. + +--- + +## When To Use + +- You have a stable type hierarchy (e.g., AST nodes, payments, UI elements) but frequently add new operations. +- You want to avoid modifying domain classes for every new behavior (formatters, validators, routers). +- You want a clear, centralized, discoverable composition point for type‑specific behavior. + +Avoid if: +- You only have a handful of operations and the types themselves are the best home for the logic (simple virtual methods may suffice). +- Pattern matching with `switch` expressions is simpler and sufficient (no need for reusable composition). + +--- + +## TL;DR Example (Result Visitor) + +```csharp +var v = Visitor + .Create() + .On(_ => "+") + .On(n => $"#{n.Value}") + .Default(_ => "?") + .Build(); + +var a = v.Visit(new Add(new Number(1), new Number(2))); // "+" +var b = v.Visit(new Number(7)); // "#7" +``` + +If you omit `.Default(...)` and no handler matches, `Visit` throws `InvalidOperationException`. Use `TryVisit` for a non‑throwing path. + +--- + +## TL;DR Example (Action Visitor) + +```csharp +var v = ActionVisitor + .Create() + .On(_ => Log("add")) + .On(_ => Count++) + .Default(_ => Skip()) + .Build(); + +v.Visit(new Add(new Number(1), new Number(2))); +``` + +See also `AsyncVisitor` and `AsyncActionVisitor` for asynchronous variants. + +--- + +## API Shape + +```csharp +var resultVisitor = Visitor.Create() + .On(static x => /* TResult */) + .Default(static (in TBase x) => /* TResult */) // optional + .Build(); + +TResult r = resultVisitor.Visit(in node); // throws if no match and no default +bool ok = resultVisitor.TryVisit(in node, out r); // non‑throwing +``` + +Key points +- Handlers and predicates use `in` parameters to avoid copying large structs. +- Registration order is evaluation order; the first matching registration runs. +- A `.Default(...)` provides a fallback when nothing matches. + +--- + +## Ordering And Specificity + +- Register more specific types before base types to prevent shadowing. +- Group related registrations for readability (e.g., all numeric nodes, then structural nodes). +- For very large hierarchies, consider composing multiple visitors for locality. + +--- + +## Defaults, Errors, And TryVisit + +- No default + no match: `Visit` throws `InvalidOperationException`. +- `TryVisit(in, out)` always avoids throwing on no‑match: it returns `false` and sets the `out` parameter to `default`. +- Prefer a `.Default(...)` for resilience and observability in production code; log and continue. + +--- + +## Performance And Thread Safety + +- Dispatch is a tight `for` loop over an array of predicates/handlers. No reflection in the hot path. +- Built visitors are immutable and safe to share across threads. Builders are not thread‑safe. +- For hot paths with many registrations, keep most frequent types early and consider splitting by module/domain. + +--- + +## Composition & DI + +Register built visitors as singletons; capture collaborators in closures or expose a factory method. + +```csharp +// Registration +services.AddSingleton>(sp => +{ + var settings = sp.GetRequiredService(); + return Visitor + .Create() + .On(t => $"Cash {t.Value,8:C}") + .On(t => $"{t.Brand} ****{t.Last4,4} {t.Value,8:C}") + .On(t => $"GiftCard {t.Code,-8} {t.Value,8:C}") + .On(t => $"StoreCredit {t.CustomerId,-6} {t.Value,8:C}") + .Default(t => settings.ShowRaw ? $"Other {t.Amount:C}" : "Other") + .Build(); +}); + +// Usage +public sealed class ReceiptService(Visitor renderer) +{ + public string LineFor(Tender t) => renderer.Visit(t); +} +``` + +Notes +- Built visitors are immutable and thread‑safe; the builder is not. +- For multi‑tenant rules, compose per‑tenant visitors at startup and select by tenant key. + +--- + +## End‑To‑End Example (POS) + +PatternKit ships a complete example that renders receipt lines and routes tenders by runtime type. + +- Example code: `src/PatternKit.Examples/VisitorDemo/VisitorDemo.cs:15` +- Walkthrough: `docs/examples/pos-visitor-routing.md:1` + +--- + +## Real‑World Recipes + +- API error mapping: translate exceptions to `ProblemDetails` or typed results. + - Example: `docs/examples/api-exception-mapping-visitor.md:1` +- Event processing: route domain events to handlers and orchestrate side effects. + - Example: `docs/examples/event-processor-visitor.md:1` +- Message routing in workers: dispatch queue messages to specialized processors with cancellation. + - Example: `docs/examples/message-router-visitor.md:1` + +These patterns keep type‑specific behavior in one place, are easy to test, and wire cleanly into DI. + +--- + +## Testing (TinyBDD style) + +```csharp +[Scenario("Result visitor dispatch and default")] +[Fact] +public Task ResultVisitor_Dispatch_And_Default() + => Given("a result visitor", () => + Visitor.Create() + .On(_ => "+") + .On(n => $"#{n.Value}") + .Default(_ => "?") + .Build()) + .When("visit three nodes", v => ( + a: v.Visit(new Add(new Number(1), new Number(2))), + b: v.Visit(new Number(7)), + c: v.Visit(new Neg(new Number(1))) // default + )) + .Then("Add -> +", r => r.a == "+") + .And("Number -> #7", r => r.b == "#7") + .And("Neg -> ?", r => r.c == "?") + .AssertPassed(); +``` + +Reference tests: `test/PatternKit.Tests/Behavioral/VisitorTests.cs:16` + +--- + +## Design Notes + +- Non‑intrusive visitor: you don’t need the classic `Accept(Visitor)` on domain types. +- Zero‑allocation dispatch; strongly‑typed handlers; no hidden boxing for value types when using `in`. +- Built on `BranchBuilder` for composable construction. + +--- + +## Gotchas + +- Missing `.Default(...)` + no match throws. Prefer `TryVisit` for defensive paths. +- Wrong registration order can shadow base types. Put most specific first. +- Actions should be idempotent; guard external side effects. + +--- + +## See Also + +- `ActionVisitor` — side‑effects only; no return +- `AsyncVisitor` — async result +- `AsyncActionVisitor` — async actions +- Examples — `docs/examples/pos-visitor-routing.md:1` +- Alternatives and comparisons — `docs/patterns/behavioral/visitor/alternatives.md:1` diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index e188437..915f046 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -48,6 +48,24 @@ href: behavioral/state/state.md - name: Memento href: behavioral/memento/memento.md + - name: Visitor + items: + - name: Visitor + href: behavioral/visitor/visitor.md + - name: ActionVisitor + href: behavioral/visitor/actionvisitor.md + - name: AsyncVisitor + href: behavioral/visitor/asyncvisitor.md + - name: AsyncActionVisitor + href: behavioral/visitor/asyncactionvisitor.md + - name: Alternatives & Comparisons + href: behavioral/visitor/alternatives.md + - name: FAQ + href: behavioral/visitor/faq.md + - name: Troubleshooting + href: behavioral/visitor/troubleshooting.md + - name: Enterprise Guide + href: behavioral/visitor/guide.md - name: Creational items: diff --git a/docs/visitor-next-steps.md b/docs/visitor-next-steps.md new file mode 100644 index 0000000..0bacf99 --- /dev/null +++ b/docs/visitor-next-steps.md @@ -0,0 +1,75 @@ +# Visitor Pattern — Next Steps Plan (Updated) + +## Objectives +- Achieve documentation and discoverability parity across Visitor variants (sync/async, result/action). +- Strengthen test coverage with concurrency and async scenarios beyond correctness. +- Provide DI/composition recipes for production usage and reuse. +- Establish baseline performance via microbenchmarks and identify optimizations. +- Explore developer ergonomics (source generators/analyzers) for large hierarchies. + +## Current Status +- Completed + - Sync `Visitor` and `ActionVisitor` APIs + XML docs. + - Async `AsyncVisitor` and `AsyncActionVisitor` APIs + XML docs. + - Core tests for sync/async visitors (dispatch, defaults, TryVisit, cancellation). + - Real‑world POS example (`VisitorDemo`) and expanded docs (overview, async, guide, examples, FAQ, troubleshooting, alternatives). + - TOC and index updated; Visitor listed as implemented. +- Gaps + - No dedicated page for `AsyncActionVisitor` (referenced but not first‑class page). + - No concurrency stress tests (multi‑threaded/parallel visit). + - No microbenchmarks (Visitor vs switch/pattern matching vs dictionary mapping). + - Missing DI/composition recipes in docs with `IServiceCollection` examples. + - No Visitor source generator/analyzers to optimize or lint usage patterns. + +## Milestones & Deliverables + +1) Documentation Parity & DI Recipes +- Add dedicated page: `docs/patterns/behavioral/visitor/asyncactionvisitor.md` +- Add “Composition & DI” sections to: + - `docs/patterns/behavioral/visitor/visitor.md` + - `docs/patterns/behavioral/visitor/asyncvisitor.md` + - Show `IServiceCollection` registration and singleton reuse patterns. +- Update TOC: add AsyncActionVisitor page under Visitor. +- DoD: Docfx builds; pages link to tests and examples; discoverable from Visitor hub. + +2) Concurrency Tests (Smoke + Deterministic) +- Files (proposed): + - `test/PatternKit.Tests/Behavioral/VisitorConcurrencyTests.cs` + - `test/PatternKit.Tests/Behavioral/AsyncVisitorConcurrencyTests.cs` +- Scenarios: + - Parallel `Visit`/`VisitAsync` across mixed node arrays; counters validate counts; no exceptions. + - Ensure default path participates correctly under concurrency. +- DoD: Tests pass deterministically on all TFMs. + +3) Microbenchmarks (Baseline Performance) +- Project: `benchmarks/PatternKit.Benchmarks` (net8.0, net9.0) +- Scenarios (per N registrations; skewed/mixed/fallback hit patterns): + - Visitor vs `switch`/pattern matching vs `Dictionary` mapping. + - Result and action variants; sync stubs; async stubs (completed ValueTasks). +- DoD: Benchmarks run locally; readme captures summary and guidance. + +4) Async Example Enrichment +- Extend POS example with an async receipt enrichment (e.g., brand lookup or external call) using `AsyncVisitor`. +- Add cancellation and timeout example; include in docs/examples. +- DoD: Example compiles, is referenced from async docs, and covered by a small test. + +5) Developer Ergonomics (Optional but Valuable) +- Visitor Source Generator + - New attribute: `GenerateVisitorAttribute` (name, base type, result/action kind). + - Emit sealed visitor with optimized type fast‑paths and builder scaffolding. +- Analyzers (Roslyn) + - Warn on missing `.Default(...)` when callers use `.Visit(...)` (risk of runtime throw). + - Warn when a base type registration appears before a subtype (shadowing risk). +- DoD: Generators/analyzers ship under `PatternKit.Generators`; tests cover typical cases. + +## Execution Notes +- Build/Test: `dotnet build PatternKit.slnx -c Release`, `dotnet test PatternKit.slnx -c Release` +- Docs: `docfx docs/docfx.json` +- Style: keep “first‑match‑wins” terminology; register specific types before base types; prefer idempotent actions. + +## Proposed Order of Work +1) Docs parity + DI recipes (fast win, improves onboarding) +2) Concurrency tests (confidence in production usage) +3) Microbenchmarks (data to guide ordering/structure guidance) +4) Async example enrichment (better story for I/O‑bound real‑world cases) +5) Generators/analyzers (ergonomics and safety for larger teams) diff --git a/src/PatternKit.Core/Behavioral/State/StateMachine.cs b/src/PatternKit.Core/Behavioral/State/StateMachine.cs index c4180cf..f7f137e 100644 --- a/src/PatternKit.Core/Behavioral/State/StateMachine.cs +++ b/src/PatternKit.Core/Behavioral/State/StateMachine.cs @@ -201,7 +201,7 @@ public sealed class StateBuilder public WhenBuilder When(Predicate predicate) => new(this, predicate); /// Default transition used when no predicate matches. - public ThenBuilder Otherwise() => new(this, static (in TEvent _) => true); + public ThenBuilder Otherwise() => new(this, static (in _) => true); /// Register an entry hook invoked after entering this state. public StateBuilder OnEnter(StateHook hook) diff --git a/src/PatternKit.Core/Behavioral/Visitor/ActionVisitor.cs b/src/PatternKit.Core/Behavioral/Visitor/ActionVisitor.cs new file mode 100644 index 0000000..a156112 --- /dev/null +++ b/src/PatternKit.Core/Behavioral/Visitor/ActionVisitor.cs @@ -0,0 +1,126 @@ +using PatternKit.Common; +using PatternKit.Creational.Builder; + +namespace PatternKit.Behavioral.Visitor; + +/// +/// Visitor (fluent, typed dispatch, action). +/// Maps runtime types deriving from to side-effecting actions and +/// executes the first matching action (first-match-wins). +/// +/// +/// +/// Use to register actions for concrete types +/// (where T : TBase). Registration order matters: register more specific types before base types. +/// If no action matches, an optional is used; otherwise +/// throws and returns . +/// +/// Thread-safety: built instances are immutable and thread-safe; the builder is not. +/// +/// The base type for visitable elements. +public sealed class ActionVisitor +{ + /// Predicate to determine if an action applies to a node. + /// The node value. + /// if the action applies; otherwise . + public delegate bool Predicate(in TBase node); + + /// Action that processes a node. + /// The node value. + public delegate void ActionHandler(in TBase node); + + private readonly Predicate[] _predicates; + private readonly ActionHandler[] _actions; + private readonly bool _hasDefault; + private readonly ActionHandler _default; + + private static ActionHandler Noop => static (in _) => { }; + + private ActionVisitor(Predicate[] predicates, ActionHandler[] actions, bool hasDefault, ActionHandler @default) + => (_predicates, _actions, _hasDefault, _default) = (predicates, actions, hasDefault, @default); + + /// Visits a node and executes the first matching action or the default. + /// The node to visit. + /// Thrown if no action matches and no default is configured. + public void Visit(in TBase node) + { + var predicates = _predicates; + for (var i = 0; i < predicates.Length; i++) + if (predicates[i](in node)) + { + _actions[i](in node); + return; + } + + if (_hasDefault) + { + _default(in node); + return; + } + + Throw.NoStrategyMatched(); + } + + /// Attempts to visit a node; never throws for no-match. + /// The node to visit. + /// if an action (or default) executed; otherwise . + public bool TryVisit(in TBase node) + { + var predicates = _predicates; + for (var i = 0; i < predicates.Length; i++) + if (predicates[i](in node)) + { + _actions[i](in node); + return true; + } + + if (_hasDefault) + { + _default(in node); + return true; + } + + return false; + } + + /// Creates a new fluent builder for an . + public static Builder Create() => new(); + + /// Fluent builder to register type-specific actions and an optional default. + public sealed class Builder + { + private readonly BranchBuilder _core = BranchBuilder.Create(); + + /// Registers an action for nodes of type . + /// A concrete type assignable to . + /// The action invoked when the runtime type is . + public Builder On(Action action) where T : TBase + { + _core.Add(Is, Wrap(action)); + return this; + } + + /// Sets a default action for nodes with no matching registration. + public Builder Default(ActionHandler action) + { + _core.Default(action); + return this; + } + + /// Sets a default action using a synchronous delegate without in parameter syntax. + public Builder Default(Action action) + => Default((in x) => action(x)); + + /// Builds the immutable, thread-safe visitor. + public ActionVisitor Build() + => _core.Build( + fallbackDefault: Noop, + projector: static (predicates, actions, hasDefault, @default) + => new ActionVisitor(predicates, actions, hasDefault, @default)); + + private static bool Is(in TBase node) where T : TBase => node is T; + + private static ActionHandler Wrap(Action typed) where T : TBase + => (in node) => typed((T)node!); + } +} diff --git a/src/PatternKit.Core/Behavioral/Visitor/AsyncActionVisitor.cs b/src/PatternKit.Core/Behavioral/Visitor/AsyncActionVisitor.cs new file mode 100644 index 0000000..3523615 --- /dev/null +++ b/src/PatternKit.Core/Behavioral/Visitor/AsyncActionVisitor.cs @@ -0,0 +1,123 @@ +using PatternKit.Common; +using PatternKit.Creational.Builder; + +namespace PatternKit.Behavioral.Visitor; + +/// +/// Visitor (fluent, typed dispatch, async action). +/// Maps runtime types deriving from to asynchronous actions and executes +/// the first matching action (first-match-wins). +/// +/// +/// +/// Use +/// to register actions for concrete types (where T : TBase). Registration order matters: register more specific +/// types before base types. If no action matches, an optional is used; +/// otherwise throws and +/// returns . +/// +/// Thread-safety: built instances are immutable and thread-safe; the builder is not. +/// +/// The base type for visitable elements. +public sealed class AsyncActionVisitor +{ + /// Asynchronous predicate to determine if an action applies. + public delegate ValueTask Predicate(TBase node, CancellationToken ct); + + /// Asynchronous action that processes a node. + public delegate ValueTask ActionHandler(TBase node, CancellationToken ct); + + private readonly Predicate[] _predicates; + private readonly ActionHandler[] _actions; + private readonly bool _hasDefault; + private readonly ActionHandler _default; + + private static ActionHandler Noop => static (_, _) => default; + + private AsyncActionVisitor(Predicate[] predicates, ActionHandler[] actions, bool hasDefault, ActionHandler @default) + => (_predicates, _actions, _hasDefault, _default) = (predicates, actions, hasDefault, @default); + + /// Visits a node and executes the first matching action or the default. + public async ValueTask VisitAsync(TBase node, CancellationToken ct = default) + { + var predicates = _predicates; + for (var i = 0; i < predicates.Length; i++) + if (await predicates[i](node, ct).ConfigureAwait(false)) + { + await _actions[i](node, ct).ConfigureAwait(false); + return; + } + + if (_hasDefault) + { + await _default(node, ct).ConfigureAwait(false); + return; + } + + Throw.NoStrategyMatched(); + } + + /// Attempts to visit a node; never throws for no-match. + public async ValueTask TryVisitAsync(TBase node, CancellationToken ct = default) + { + var predicates = _predicates; + for (var i = 0; i < predicates.Length; i++) + if (await predicates[i](node, ct).ConfigureAwait(false)) + { + await _actions[i](node, ct).ConfigureAwait(false); + return true; + } + + if (_hasDefault) + { + await _default(node, ct).ConfigureAwait(false); + return true; + } + + return false; + } + + /// Creates a new fluent builder for an . + public static Builder Create() => new(); + + /// Fluent builder to register type-specific async actions and an optional default. + public sealed class Builder + { + private readonly BranchBuilder _core = BranchBuilder.Create(); + + /// Registers an async action for nodes of type . + public Builder On(Func action) where T : TBase + { + _core.Add(Is, Wrap(action)); + return this; + } + + /// Registers a sync action for nodes of type . + public Builder On(Action action) where T : TBase + => On((x, _) => { action(x); return default; }); + + /// Sets an async default action for nodes with no matching registration. + public Builder Default(ActionHandler action) + { + _core.Default(action); + return this; + } + + /// Sets a sync default action for nodes with no matching registration. + public Builder Default(Action action) + => Default((x, _) => { action(x); return default; }); + + /// Builds the immutable, thread-safe visitor. + public AsyncActionVisitor Build() + => _core.Build( + fallbackDefault: Noop, + projector: static (predicates, actions, hasDefault, @default) + => new AsyncActionVisitor(predicates, actions, hasDefault, @default)); + + private static ValueTask Is(TBase node, CancellationToken _) where T : TBase + => new(node is T); + + private static ActionHandler Wrap(Func typed) where T : TBase + => (node, ct) => typed((T)node!, ct); + } +} diff --git a/src/PatternKit.Core/Behavioral/Visitor/AsyncVisitor.cs b/src/PatternKit.Core/Behavioral/Visitor/AsyncVisitor.cs new file mode 100644 index 0000000..0157a42 --- /dev/null +++ b/src/PatternKit.Core/Behavioral/Visitor/AsyncVisitor.cs @@ -0,0 +1,125 @@ +using PatternKit.Common; +using PatternKit.Creational.Builder; + +namespace PatternKit.Behavioral.Visitor; + +/// +/// Visitor (fluent, typed dispatch, async result). +/// Maps runtime types deriving from to asynchronous handlers that +/// produce a and executes the first matching handler (first-match-wins). +/// +/// +/// +/// Register handlers with . +/// Registration order matters; add more specific types before base types. If no handler matches, an optional +/// is used; otherwise +/// throws and returns (false, default). +/// +/// Thread-safety: built instances are immutable and thread-safe; the builder is not thread-safe. +/// +/// The base type for visitable elements. +/// The result type of visit operations. +public sealed class AsyncVisitor +{ + /// Asynchronous predicate to determine if a handler applies. + public delegate ValueTask Predicate(TBase node, CancellationToken ct); + + /// Asynchronous handler that processes a node and returns a result. + public delegate ValueTask Handler(TBase node, CancellationToken ct); + + private readonly Predicate[] _predicates; + private readonly Handler[] _handlers; + private readonly bool _hasDefault; + private readonly Handler _default; + + private static Handler DefaultResult => static (_, _) => new ValueTask(default(TResult)!); + + private AsyncVisitor(Predicate[] predicates, Handler[] handlers, bool hasDefault, Handler @default) + => (_predicates, _handlers, _hasDefault, _default) = (predicates, handlers, hasDefault, @default); + + /// Visits a node and returns the result of the first matching handler or the default. + /// The node to visit. + /// Cancellation token. + /// The result of the matched handler, or the default if configured. + /// Thrown if no handler matches and no default is configured. + public async ValueTask VisitAsync(TBase node, CancellationToken ct = default) + { + var predicates = _predicates; + for (var i = 0; i < predicates.Length; i++) + if (await predicates[i](node, ct).ConfigureAwait(false)) + return await _handlers[i](node, ct).ConfigureAwait(false); + + if (_hasDefault) + return await _default(node, ct).ConfigureAwait(false); + + return Throw.NoStrategyMatched(); + } + + /// Attempts to visit a node; never throws for no-match. Returns a tuple indicating success and result. + public async ValueTask<(bool ok, TResult result)> TryVisitAsync(TBase node, CancellationToken ct = default) + { + var predicates = _predicates; + for (var i = 0; i < predicates.Length; i++) + if (await predicates[i](node, ct).ConfigureAwait(false)) + { + var value = await _handlers[i](node, ct).ConfigureAwait(false); + return (true, value); + } + + if (_hasDefault) + { + var value = await _default(node, ct).ConfigureAwait(false); + return (true, value); + } + + return (false, default!); + } + + /// Create a new fluent builder for a . + public static Builder Create() => new(); + + /// Fluent builder to register type-specific async handlers and an optional default. + public sealed class Builder + { + private readonly BranchBuilder _core = BranchBuilder.Create(); + + /// Registers an async handler for nodes of type . + public Builder On(Func> handler) where T : TBase + { + _core.Add(Is, Wrap(handler)); + return this; + } + + /// Registers a sync handler for nodes of type . + public Builder On(Func handler) where T : TBase + => On((x, _) => new ValueTask(handler(x))); + + /// Registers a constant result for nodes of type . + public Builder On(TResult constant) where T : TBase + => On((_, _) => new ValueTask(constant)); + + /// Sets an async default handler for nodes with no matching registration. + public Builder Default(Handler handler) + { + _core.Default(handler); + return this; + } + + /// Sets a sync default handler for nodes with no matching registration. + public Builder Default(Func handler) + => Default((x, _) => new ValueTask(handler(x))); + + /// Builds the immutable, thread-safe visitor. + public AsyncVisitor Build() + => _core.Build( + fallbackDefault: DefaultResult, + projector: static (predicates, handlers, hasDefault, @default) + => new AsyncVisitor(predicates, handlers, hasDefault, @default)); + + private static ValueTask Is(TBase node, CancellationToken _) where T : TBase + => new(node is T); + + private static Handler Wrap(Func> typed) where T : TBase + => (node, ct) => typed((T)node!, ct); + } +} diff --git a/src/PatternKit.Core/Behavioral/Visitor/Visitor.cs b/src/PatternKit.Core/Behavioral/Visitor/Visitor.cs new file mode 100644 index 0000000..2812e63 --- /dev/null +++ b/src/PatternKit.Core/Behavioral/Visitor/Visitor.cs @@ -0,0 +1,135 @@ +using PatternKit.Common; +using PatternKit.Creational.Builder; + +namespace PatternKit.Behavioral.Visitor; + +/// +/// Visitor (fluent, typed dispatch, result). +/// Maps runtime types deriving from to result-producing handlers +/// and executes the first matching handler (first-match-wins). +/// +/// +/// +/// Use to register handlers for concrete types +/// (where T : TBase). Registration order matters: register more specific types before base types. +/// If no handler matches, an optional is used; otherwise +/// throws and returns . +/// +/// +/// Thread-safety: instances built via are immutable and thread-safe for +/// concurrent use, assuming supplied handlers are themselves thread-safe. The builder is not thread-safe. +/// +/// +/// Performance: dispatch is performed by evaluating registered predicates in order. For large numbers of +/// registrations or hot paths, consider grouping by type or composing multiple visitors for locality. +/// +/// +/// The base type for visitable elements. +/// The return type of visit operations. +public sealed class Visitor +{ + /// Predicate to determine if a handler applies to a node. + /// The node value. + /// if the handler applies; otherwise . + public delegate bool Predicate(in TBase node); + + /// Handler that processes a node and returns a result. + /// The node value. + /// The computed result. + public delegate TResult Handler(in TBase node); + + private readonly Predicate[] _predicates; + private readonly Handler[] _handlers; + private readonly bool _hasDefault; + private readonly Handler _default; + + private static Handler DefaultResult => static (in _) => default!; + + private Visitor(Predicate[] predicates, Handler[] handlers, bool hasDefault, Handler @default) + => (_predicates, _handlers, _hasDefault, _default) = (predicates, handlers, hasDefault, @default); + + /// Visits a node and returns the result of the first matching handler or the default. + /// The node to visit. + /// The result of the matched handler, or the default if configured. + /// Thrown if no handler matches and no default is configured. + public TResult Visit(in TBase node) + { + var predicates = _predicates; + for (var i = 0; i < predicates.Length; i++) + if (predicates[i](in node)) + return _handlers[i](in node); + + return _hasDefault + ? _default(in node) + : Throw.NoStrategyMatched(); + } + + /// Attempts to visit a node; never throws for no-match. + /// The node to visit. + /// The result if a handler or default executed. + /// if a handler (or default) executed; otherwise . + public bool TryVisit(in TBase node, out TResult result) + { + var predicates = _predicates; + for (var i = 0; i < predicates.Length; i++) + if (predicates[i](in node)) + { + result = _handlers[i](in node); + return true; + } + + if (_hasDefault) + { + result = _default(in node); + return true; + } + + result = default!; + return false; + } + + /// Creates a new fluent builder for a . + public static Builder Create() => new(); + + /// Fluent builder to register type-specific handlers and an optional default. + public sealed class Builder + { + private readonly BranchBuilder _core = BranchBuilder.Create(); + + /// Registers a handler for nodes of type . + /// A concrete type assignable to . + /// The handler invoked when the runtime type is . + public Builder On(Func handler) where T : TBase + { + _core.Add(Is, Wrap(handler)); + return this; + } + + /// Registers a constant result for nodes of type . + public Builder On(TResult constant) where T : TBase + => On(_ => constant); + + /// Sets a default handler for nodes with no matching registration. + public Builder Default(Handler handler) + { + _core.Default(handler); + return this; + } + + /// Sets a default handler using a synchronous delegate without in parameter syntax. + public Builder Default(Func handler) + => Default((in x) => handler(x)); + + /// Builds the immutable, thread-safe visitor. + public Visitor Build() + => _core.Build( + fallbackDefault: DefaultResult, + projector: static (predicates, handlers, hasDefault, @default) + => new Visitor(predicates, handlers, hasDefault, @default)); + + private static bool Is(in TBase node) where T : TBase => node is T; + + private static Handler Wrap(Func typed) where T : TBase + => (in node) => typed((T)node!); + } +} diff --git a/src/PatternKit.Examples/ApiGateway/MiniRouter.cs b/src/PatternKit.Examples/ApiGateway/MiniRouter.cs index b2f8560..1b2395a 100644 --- a/src/PatternKit.Examples/ApiGateway/MiniRouter.cs +++ b/src/PatternKit.Examples/ApiGateway/MiniRouter.cs @@ -2,7 +2,13 @@ namespace PatternKit.Examples.ApiGateway; -// Ultra-minimal request/response types used by the demo. +/// +/// Minimal HTTP-like request used by the API gateway demo. +/// +/// HTTP method (e.g., GET, POST). +/// Request path (e.g., /orders/123). +/// Request headers map. +/// Optional raw body string. public readonly record struct Request( string Method, string Path, @@ -10,23 +16,40 @@ public readonly record struct Request( string? Body = null ); +/// +/// Minimal HTTP-like response produced by routes. +/// +/// HTTP status code. +/// MIME content type. +/// Raw response body. public readonly record struct Response( int StatusCode, string ContentType, string Body ); +/// +/// Small helpers to build common shapes. +/// public static class Responses { + /// Create a text/plain response with status and body. + /// HTTP status code. + /// Response text body. public static Response Text(int status, string body) => new(status, "text/plain; charset=utf-8", body); + /// Create an application/json response with status and pre‑serialized JSON body. + /// HTTP status code. + /// Serialized JSON string. public static Response Json(int status, string json) => new(status, "application/json; charset=utf-8", json); + /// Build a standard 404 Not Found response. public static Response NotFound() => Text(404, "Not Found"); + /// Build a standard 401 Unauthorized response. public static Response Unauthorized() => Text(401, "Unauthorized"); } @@ -35,6 +58,9 @@ public static Response Unauthorized() /// A tiny API gateway/router showing how ActionStrategy + Strategy + TryStrategy /// compose into a pragmatic HTTP-ish pipeline. /// +/// +/// A tiny API gateway/router composing middleware, routes, and content negotiation. +/// public sealed class MiniRouter { private readonly ActionStrategy _middleware; @@ -47,6 +73,11 @@ private MiniRouter( TryStrategy negotiate) => (_middleware, _routes, _negotiate) = (middleware, routes, negotiate); + /// + /// Processes a request through middleware, routes, and content negotiation. + /// + /// The incoming request. + /// The route response with content type negotiated. public Response Handle(in Request req) { // fire first-matching side-effect (e.g., logging, auth short-circuit) @@ -63,6 +94,7 @@ public Response Handle(in Request req) return res; } + /// Create a new builder for . public static Builder Create() => new(); public sealed class Builder @@ -99,6 +131,7 @@ public Builder WithNegotiator(TryStrategy negotiator) return this; } + /// Builds an immutable router instance. public MiniRouter Build() { // Middleware default: do nothing if nothing matched @@ -110,6 +143,9 @@ public MiniRouter Build() return new MiniRouter(mw, routes, neg); } + /// + /// Default content negotiator that picks JSON if requested, then text, otherwise JSON. + /// private static TryStrategy DefaultNegotiator() { // Tiny Accept negotiator: @@ -149,4 +185,4 @@ private static TryStrategy DefaultNegotiator() .Build(); } } -} \ No newline at end of file +} diff --git a/src/PatternKit.Examples/VisitorDemo/VisitorDemo.cs b/src/PatternKit.Examples/VisitorDemo/VisitorDemo.cs new file mode 100644 index 0000000..a27fafd --- /dev/null +++ b/src/PatternKit.Examples/VisitorDemo/VisitorDemo.cs @@ -0,0 +1,146 @@ +using PatternKit.Behavioral.Visitor; + +namespace PatternKit.Examples.VisitorDemo; + +/// +/// Base type for payment tenders in a POS/integration flow. +/// Each tender carries an used for totals and fallbacks. +/// +/// The monetary amount represented by this tender. +public abstract record Tender(decimal Amount); + +/// +/// Cash tender representing physical currency. +/// +/// The cash value paid. +public sealed record Cash(decimal Value) : Tender(Value); + +/// +/// Card tender (credit/debit) including brand and masked PAN. +/// +/// Card brand (e.g., VISA, MC). +/// Masked PAN suffix for audit display. +/// Authorized charge amount. +public sealed record Card(string Brand, string Last4, decimal Value) : Tender(Value); + +/// +/// Gift card tender redeemed by code. +/// +/// Gift card code displayed on receipt. +/// Redeemed amount. +public sealed record GiftCard(string Code, decimal Value) : Tender(Value); + +/// +/// Store credit tender tied to a customer identity. +/// +/// Internal customer identifier. +/// Credit amount applied. +public sealed record StoreCredit(string CustomerId, decimal Value) : Tender(Value); + +/// +/// Catch‑all tender for sources that do not have a dedicated type. +/// Useful for integration bring‑up and minimizing runtime failures. +/// +/// Short label for the tender source. +/// Applied amount. +public sealed record Unknown(string Description, decimal Value) : Tender(Value); + +public static class ReceiptRendering +{ + /// + /// Creates a result visitor that formats tenders into printable receipt lines. + /// Includes a default formatter for unknown tender types. + /// + /// A reusable, thread‑safe visitor instance. + public static Visitor CreateRenderer() => Visitor + .Create() + .On(t => $"Cash {t.Value,8:C}") + .On(t => $"{t.Brand} ****{t.Last4,4} {t.Value,8:C}") + .On(t => $"GiftCard {t.Code,-8} {t.Value,8:C}") + .On(t => $"StoreCredit {t.CustomerId,-6} {t.Value,8:C}") + .Default(t => $"Other {t.Amount,8:C}") + .Build(); +} + +public interface ITenderHandler +{ + /// Handles tenders. + /// The cash tender. + void Cash(Cash t); + /// Handles tenders. + /// The card tender. + void Card(Card t); + /// Handles tenders. + /// The gift card tender. + void Gift(GiftCard t); + /// Handles tenders. + /// The store credit tender. + void Credit(StoreCredit t); + /// Fallback for tenders that have no specialized handler. + /// The unknown tender. + void Fallback(Tender t); +} + +public sealed class CountersHandler : ITenderHandler +{ + /// Total number of cash tenders processed. + public int CashCount, CardCount, GiftCount, CreditCount, FallbackCount; + /// Aggregated amount across all tenders processed. + public decimal Total; + public void Cash(Cash t) { CashCount++; Total += t.Value; } + public void Card(Card t) { CardCount++; Total += t.Value; } + public void Gift(GiftCard t) { GiftCount++; Total += t.Value; } + public void Credit(StoreCredit t) { CreditCount++; Total += t.Value; } + public void Fallback(Tender t) { FallbackCount++; Total += t.Amount; } +} + +public static class Routing +{ + /// + /// Creates an action visitor that routes tenders to the provided handler. + /// + /// The handler receiving type‑specific callbacks. + /// A reusable, thread‑safe router. + public static ActionVisitor CreateRouter(ITenderHandler handler) => ActionVisitor + .Create() + .On(handler.Cash) + .On(handler.Card) + .On(handler.Gift) + .On(handler.Credit) + .Default(handler.Fallback) + .Build(); +} + +public static class Demo +{ + /// + /// Runs an end‑to‑end demo: routes tenders, then renders receipt lines. + /// + /// + /// A tuple containing the rendered receipt lines and the processing counters + /// collected by . + /// + public static (string[] receipt, CountersHandler counters) Run() + { + var renderer = ReceiptRendering.CreateRenderer(); + var counters = new CountersHandler(); + var router = Routing.CreateRouter(counters); + + var tenders = new Tender[] + { + new Cash(10.00m), + new Card("VISA", "4242", 15.75m), + new GiftCard("GFT-001", 5.00m), + new StoreCredit("C123", 3.25m), + new Unknown("PromoVoucher", 2.00m), + }; + + foreach (var t in tenders) + { + router.Visit(t); + } + + var lines = tenders.Select(t => renderer.Visit(t)).ToArray(); + return (lines, counters); + } +} diff --git a/src/PatternKit.Generators/packages.lock.json b/src/PatternKit.Generators/packages.lock.json index 4eb9db9..40a2a74 100644 --- a/src/PatternKit.Generators/packages.lock.json +++ b/src/PatternKit.Generators/packages.lock.json @@ -117,6 +117,194 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.3" } } + }, + ".NETStandard,Version=v2.1": { + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Direct", + "requested": "[3.11.0, )", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[4.14.0, )", + "resolved": "4.14.0", + "contentHash": "568a6wcTivauIhbeWcCwfWwIn7UV7MeHEBvFB2uzGIpM2OhJ4eM/FZ8KS0yhPoNxnSpjGzz7x7CIjTxhslojQA==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[4.14.0]", + "System.Buffers": "4.5.1", + "System.Collections.Immutable": "9.0.0", + "System.Memory": "4.5.5", + "System.Numerics.Vectors": "4.5.0", + "System.Reflection.Metadata": "9.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encoding.CodePages": "7.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Collections.Immutable": { + "type": "Direct", + "requested": "[9.0.9, )", + "resolved": "9.0.9", + "contentHash": "/kpkgDxH984e3J3z5v/DIFi+0TWbUJXS8HNKUYBy3YnXtK09JVGs3cw5aOV6fDSw5NxbWLWlGrYjRteu6cjX3w==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "4.14.0", + "contentHash": "PC3tuwZYnC+idaPuoC/AZpEdwrtX7qFpmnrfQkgobGIWiYmGi5MCRtl5mx6QrfMGQpK78X2lfIEoZDLg/qnuHg==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "System.Buffers": "4.5.1", + "System.Collections.Immutable": "9.0.0", + "System.Memory": "4.5.5", + "System.Numerics.Vectors": "4.5.0", + "System.Reflection.Metadata": "9.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encoding.CodePages": "7.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.4.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "ANiqLu3DxW9kol/hMmTWbt3414t9ftdIuiIU7j80okq2YzAueo120M442xk1kDJWtmZTqWQn7wHDvMRipVOEOQ==", + "dependencies": { + "System.Collections.Immutable": "9.0.0", + "System.Memory": "4.5.5" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "LSyCblMpvOe0N3E+8e0skHcrIhgV2huaNcjUUEa8hRtgEAm36aGkRoC8Jxlb6Ra6GSfF29ftduPNywin8XolzQ==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + } + }, + "net8.0": { + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Direct", + "requested": "[3.11.0, )", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[4.14.0, )", + "resolved": "4.14.0", + "contentHash": "568a6wcTivauIhbeWcCwfWwIn7UV7MeHEBvFB2uzGIpM2OhJ4eM/FZ8KS0yhPoNxnSpjGzz7x7CIjTxhslojQA==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[4.14.0]", + "System.Collections.Immutable": "9.0.0", + "System.Reflection.Metadata": "9.0.0" + } + }, + "System.Collections.Immutable": { + "type": "Direct", + "requested": "[9.0.9, )", + "resolved": "9.0.9", + "contentHash": "/kpkgDxH984e3J3z5v/DIFi+0TWbUJXS8HNKUYBy3YnXtK09JVGs3cw5aOV6fDSw5NxbWLWlGrYjRteu6cjX3w==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "4.14.0", + "contentHash": "PC3tuwZYnC+idaPuoC/AZpEdwrtX7qFpmnrfQkgobGIWiYmGi5MCRtl5mx6QrfMGQpK78X2lfIEoZDLg/qnuHg==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "System.Collections.Immutable": "9.0.0", + "System.Reflection.Metadata": "9.0.0" + } + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "ANiqLu3DxW9kol/hMmTWbt3414t9ftdIuiIU7j80okq2YzAueo120M442xk1kDJWtmZTqWQn7wHDvMRipVOEOQ==", + "dependencies": { + "System.Collections.Immutable": "9.0.0" + } + } + }, + "net9.0": { + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Direct", + "requested": "[3.11.0, )", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[4.14.0, )", + "resolved": "4.14.0", + "contentHash": "568a6wcTivauIhbeWcCwfWwIn7UV7MeHEBvFB2uzGIpM2OhJ4eM/FZ8KS0yhPoNxnSpjGzz7x7CIjTxhslojQA==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[4.14.0]", + "System.Collections.Immutable": "9.0.0", + "System.Reflection.Metadata": "9.0.0" + } + }, + "System.Collections.Immutable": { + "type": "Direct", + "requested": "[9.0.9, )", + "resolved": "9.0.9", + "contentHash": "/kpkgDxH984e3J3z5v/DIFi+0TWbUJXS8HNKUYBy3YnXtK09JVGs3cw5aOV6fDSw5NxbWLWlGrYjRteu6cjX3w==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "4.14.0", + "contentHash": "PC3tuwZYnC+idaPuoC/AZpEdwrtX7qFpmnrfQkgobGIWiYmGi5MCRtl5mx6QrfMGQpK78X2lfIEoZDLg/qnuHg==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "System.Collections.Immutable": "9.0.0", + "System.Reflection.Metadata": "9.0.0" + } + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "ANiqLu3DxW9kol/hMmTWbt3414t9ftdIuiIU7j80okq2YzAueo120M442xk1kDJWtmZTqWQn7wHDvMRipVOEOQ==" + } } } } \ No newline at end of file diff --git a/test/PatternKit.Tests/Behavioral/ActionVisitorExtraTests.cs b/test/PatternKit.Tests/Behavioral/ActionVisitorExtraTests.cs new file mode 100644 index 0000000..a313e6c --- /dev/null +++ b/test/PatternKit.Tests/Behavioral/ActionVisitorExtraTests.cs @@ -0,0 +1,65 @@ +using PatternKit.Behavioral.Visitor; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Behavioral; + +[Feature("Behavioral - ActionVisitor edge cases")] +public sealed class ActionVisitorExtraTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + private abstract record Node; + private sealed record Number(int Value) : Node; + private sealed record Add(Node Left, Node Right) : Node; + private sealed record Neg(Node Inner) : Node; + + [Scenario("Visit throws when no match and no default")] + [Fact] + public Task ActionVisitor_Throws_NoDefault() + => Given("visitor without default", () => + ActionVisitor.Create().On(_ => { }).Build()) + .When("visiting Neg", ExpectInvalidOp) + .Then("threw InvalidOperationException", threw => threw) + .AssertPassed(); + + private static bool ExpectInvalidOp(ActionVisitor v) + { + try + { + v.Visit(new Neg(new Number(1))); + return false; + } + catch (InvalidOperationException) + { + return true; + } + } + + [Scenario("TryVisit returns true when default configured")] + [Fact] + public Task ActionVisitor_TryVisit_Default_ReturnsTrue() + => Given("visitor with default", () => + ActionVisitor.Create().Default(_ => { }).Build()) + .When("TryVisit Neg", v => v.TryVisit(new Neg(new Number(2)))) + .Then("ok == true", ok => ok) + .AssertPassed(); + + [Scenario("Registration order matters: base before derived")] + [Fact] + public Task ActionVisitor_Order_Matters() + => Given("counters and visitor with base first", () => + { + var counters = new int[3]; // [0]=Add, [1]=Number, [2]=Node + var v = ActionVisitor + .Create() + .On(_ => Interlocked.Increment(ref counters[2])) // base first + .On(_ => Interlocked.Increment(ref counters[1])) // derived later (won't hit) + .Build(); + return (v, counters); + }) + .When("visiting Number", x => { x.v.Visit(new Number(5)); return x.counters; }) + .Then("base handler executed", c => c[2] == 1) + .And("derived handler did not execute", c => c[1] == 0) + .AssertPassed(); +} + diff --git a/test/PatternKit.Tests/Behavioral/AsyncActionVisitorTests.cs b/test/PatternKit.Tests/Behavioral/AsyncActionVisitorTests.cs new file mode 100644 index 0000000..33fcbd9 --- /dev/null +++ b/test/PatternKit.Tests/Behavioral/AsyncActionVisitorTests.cs @@ -0,0 +1,134 @@ +using PatternKit.Behavioral.Visitor; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Behavioral; + +[Feature("Behavioral - AsyncActionVisitor Basics")] +public sealed class AsyncActionVisitorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + private abstract record Node; + private sealed record Number(int Value) : Node; + private sealed record Add(Node Left, Node Right) : Node; + private sealed record Neg(Node Inner) : Node; + + [Scenario("On(Action) executes for matching type")] + [Fact] + public Task AsyncActionVisitor_SyncOn_Executes() + => Given("counters and visitor using sync On", () => + { + var counters = new int[3]; + var v = AsyncActionVisitor + .Create() + .On(_ => Interlocked.Increment(ref counters[0])) + .Build(); + return (v, counters); + }) + .When("visiting Add", VisitAdd) + .Then("Add counter incremented", x => x.counters[0] == 1) + .AssertPassed(); + + private static async Task<(AsyncActionVisitor v, int[] counters)> VisitAdd((AsyncActionVisitor v, int[] counters) x) + { + await x.v.VisitAsync(new Add(new Number(1), new Number(2))); + return x; + } + + [Scenario("TryVisitAsync returns false when no default and no match")] + [Fact] + public Task AsyncActionVisitor_TryVisit_NoDefault() + => Given("visitor with single handler", () => + AsyncActionVisitor.Create().On(_ => { }).Build()) + .When("TryVisit Neg", TryNeg) + .Then("ok == false", ok => !ok) + .AssertPassed(); + + private static async Task TryNeg(AsyncActionVisitor v) + => await v.TryVisitAsync(new Neg(new Number(0))); + + [Scenario("VisitAsync throws when no match and no default")] + [Fact] + public Task AsyncActionVisitor_Throws_When_NoMatch_NoDefault() + => Given("visitor with only Number handler", () => + AsyncActionVisitor.Create().On(_ => { }).Build()) + .When("visiting Neg", ExpectInvalidOp) + .Then("threw InvalidOperationException", threw => threw) + .AssertPassed(); + + private static async Task ExpectInvalidOp(AsyncActionVisitor v) + { + try + { + await v.VisitAsync(new Neg(new Number(1))); + return false; + } + catch (InvalidOperationException) + { + return true; + } + } + + [Scenario("Default async handler executes for unmatched type")] + [Fact] + public Task AsyncActionVisitor_DefaultAsync_Executes() + => Given("counters and visitor with async default", () => + { + var counters = new int[3]; + var v = AsyncActionVisitor + .Create() + .On(_ => { Interlocked.Increment(ref counters[1]); }) + .Default((_, _) => { Interlocked.Increment(ref counters[2]); return default; }) + .Build(); + return (v, counters); + }) + .When("visiting Neg", VisitNeg) + .Then("default counter incremented", x => x.counters[2] == 1) + .AssertPassed(); + + [Scenario("Default sync action executes for unmatched type")] + [Fact] + public Task AsyncActionVisitor_DefaultSync_Executes() + => Given("counters and visitor with sync default", () => + { + var counters = new int[3]; + var v = AsyncActionVisitor + .Create() + .On(_ => { Interlocked.Increment(ref counters[1]); }) + .Default(_ => { Interlocked.Increment(ref counters[2]); }) + .Build(); + return (v, counters); + }) + .When("visiting Neg", VisitNeg) + .Then("default counter incremented", x => x.counters[2] == 1) + .AssertPassed(); + + private static async Task<(AsyncActionVisitor v, int[] counters)> VisitNeg((AsyncActionVisitor v, int[] counters) x) + { + await x.v.VisitAsync(new Neg(new Number(9))); + return x; + } + + [Scenario("TryVisitAsync returns true when default configured")] + [Fact] + public Task AsyncActionVisitor_TryVisit_With_Default_ReturnsTrue() + => Given("visitor with default", () => + { + var counters = new int[3]; + var v = AsyncActionVisitor + .Create() + .Default(_ => { Interlocked.Increment(ref counters[2]); }) + .Build(); + return (v, counters); + }) + .When("TryVisit Neg", TryVisitNegDefault) + .Then("ok == true", r => r.ok) + .And("default executed once", r => r.counters[2] == 1) + .AssertPassed(); + + private static async Task<(bool ok, int[] counters)> TryVisitNegDefault((AsyncActionVisitor v, int[] counters) x) + { + var ok = await x.v.TryVisitAsync(new Neg(new Number(0))); + return (ok, x.counters); + } +} diff --git a/test/PatternKit.Tests/Behavioral/AsyncVisitorConcurrencyTests.cs b/test/PatternKit.Tests/Behavioral/AsyncVisitorConcurrencyTests.cs new file mode 100644 index 0000000..985cdb5 --- /dev/null +++ b/test/PatternKit.Tests/Behavioral/AsyncVisitorConcurrencyTests.cs @@ -0,0 +1,66 @@ +using PatternKit.Behavioral.Visitor; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Behavioral; + +[Feature("Behavioral - Visitor Concurrency (Async)")] +public sealed class AsyncVisitorConcurrencyTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + private sealed record State(AsyncActionVisitor v, int[] counters, Node[] nodes, int perType); + + private abstract record Node; + private sealed record Number(int Value) : Node; + private sealed record Add(Node Left, Node Right) : Node; + private sealed record Neg(Node Inner) : Node; + + [Scenario("AsyncActionVisitor executes correctly under concurrent calls across tasks")] + [Fact] + public Task AsyncActionVisitor_Concurrent_Dispatch() + => Given("an async action visitor and a mixed dataset", () => + { + var counters = new int[3]; // [0]=Add, [1]=Number, [2]=Default + var v = AsyncActionVisitor + .Create() + .On((_, _) => { Interlocked.Increment(ref counters[0]); return default; }) + .On((_, _) => { Interlocked.Increment(ref counters[1]); return default; }) + .Default((_, _) => { Interlocked.Increment(ref counters[2]); return default; }) + .Build(); + + const int perType = 1500; + var baseNodes = new Node[] { new Add(new Number(1), new Number(2)), new Number(7), new Neg(new Number(0)) }; + var nodes = new Node[perType * baseNodes.Length]; + for (var i = 0; i < perType; i++) + { + nodes[3 * i + 0] = baseNodes[0]; + nodes[3 * i + 1] = baseNodes[1]; + nodes[3 * i + 2] = baseNodes[2]; + } + + return new State(v, counters, nodes, perType); + }) + .When("visiting concurrently with tasks", VisitConcurrently) + .Then("each action ran expected times", x => x.counters[0] == x.perType && x.counters[1] == x.perType && x.counters[2] == x.perType) + .AssertPassed(); + + private static async Task VisitConcurrently(State x) + { + var degree = Math.Min(Environment.ProcessorCount, 8); + var total = x.nodes.Length; + var slice = total / degree; + var tasks = new List(degree); + for (var i = 0; i < degree; i++) + { + var start = i * slice; + var end = (i == degree - 1) ? total : start + slice; + tasks.Add(Task.Run(async () => + { + for (var j = start; j < end; j++) + await x.v.VisitAsync(x.nodes[j]); + })); + } + await Task.WhenAll(tasks); + return x; + } +} diff --git a/test/PatternKit.Tests/Behavioral/AsyncVisitorResultConcurrencyTests.cs b/test/PatternKit.Tests/Behavioral/AsyncVisitorResultConcurrencyTests.cs new file mode 100644 index 0000000..03a741f --- /dev/null +++ b/test/PatternKit.Tests/Behavioral/AsyncVisitorResultConcurrencyTests.cs @@ -0,0 +1,69 @@ +using PatternKit.Behavioral.Visitor; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Behavioral; + +[Feature("Behavioral - Visitor Concurrency (Async Result)")] +public sealed class AsyncVisitorResultConcurrencyTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + private sealed record State(AsyncVisitor v, int[] counters, Node[] nodes, int perType); + + private abstract record Node; + private sealed record Number(int Value) : Node; + private sealed record Add(Node Left, Node Right) : Node; + private sealed record Neg(Node Inner) : Node; + + [Scenario("Async Result Visitor returns correct codes under concurrent tasks")] + [Fact] + public Task AsyncResultVisitor_Concurrent_Dispatch() + => Given("an async result visitor and a mixed dataset", () => + { + var counters = new int[3]; // [0]=Add, [1]=Number, [2]=Default + var v = AsyncVisitor + .Create() + .On((_, _) => new ValueTask(0)) + .On((_, _) => new ValueTask(1)) + .Default((_, _) => new ValueTask(2)) + .Build(); + + const int perType = 1500; + var baseNodes = new Node[] { new Add(new Number(1), new Number(2)), new Number(7), new Neg(new Number(0)) }; + var nodes = new Node[perType * baseNodes.Length]; + for (var i = 0; i < perType; i++) + { + nodes[3 * i + 0] = baseNodes[0]; + nodes[3 * i + 1] = baseNodes[1]; + nodes[3 * i + 2] = baseNodes[2]; + } + + return new State(v, counters, nodes, perType); + }) + .When("visiting concurrently and tallying results", VisitConcurrently) + .Then("each code occurred expected times", x => x.counters[0] == x.perType && x.counters[1] == x.perType && x.counters[2] == x.perType) + .AssertPassed(); + + private static async Task VisitConcurrently(State x) + { + var degree = Math.Min(Environment.ProcessorCount, 8); + var total = x.nodes.Length; + var slice = total / degree; + var tasks = new List(degree); + for (var i = 0; i < degree; i++) + { + var start = i * slice; + var end = (i == degree - 1) ? total : start + slice; + tasks.Add(Task.Run(async () => + { + for (var j = start; j < end; j++) + { + var code = await x.v.VisitAsync(x.nodes[j]); + Interlocked.Increment(ref x.counters[code]); + } + })); + } + await Task.WhenAll(tasks); + return x; + } +} diff --git a/test/PatternKit.Tests/Behavioral/AsyncVisitorTests.cs b/test/PatternKit.Tests/Behavioral/AsyncVisitorTests.cs new file mode 100644 index 0000000..54a32d6 --- /dev/null +++ b/test/PatternKit.Tests/Behavioral/AsyncVisitorTests.cs @@ -0,0 +1,118 @@ +using PatternKit.Behavioral.Visitor; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Behavioral; + +[Feature("Behavioral - AsyncVisitor Basics")] +public sealed class AsyncVisitorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + private abstract record Node; + private sealed record Number(int Value) : Node; + private sealed record Add(Node Left, Node Right) : Node; + private sealed record Neg(Node Inner) : Node; + + [Scenario("AsyncVisitor constant overload and default handler")] + [Fact] + public Task AsyncVisitor_Constant_And_Default() + => Given("a visitor with constants and default", () => + AsyncVisitor + .Create() + .On(42) + .On(1) + .Default((_, _) => new ValueTask(9)) + .Build()) + .When("visiting Add, Number, Neg", VisitThree) + .Then("Add -> 42", r => r.a == 42) + .And("Number -> 1", r => r.b == 1) + .And("Neg -> 9 (default)", r => r.c == 9) + .AssertPassed(); + + private static async Task<(int a, int b, int c)> VisitThree(AsyncVisitor v) + { + var a = await v.VisitAsync(new Add(new Number(1), new Number(2))); + var b = await v.VisitAsync(new Number(7)); + var c = await v.VisitAsync(new Neg(new Number(0))); + return (a, b, c); + } + + [Scenario("TryVisitAsync returns false and default when no default configured")] + [Fact] + public Task AsyncVisitor_TryVisit_NoDefault() + => Given("a visitor without default", () => + AsyncVisitor.Create() + .On((n, _) => new ValueTask(n.Value.ToString())) + .Build()) + .When("TryVisit Neg", TryNeg) + .Then("ok == false", r => r.ok == false) + .And("result is null", r => r.result is null) + .AssertPassed(); + + private static async Task<(bool ok, string? result)> TryNeg(AsyncVisitor v) + => await v.TryVisitAsync(new Neg(new Number(3))); + + [Scenario("VisitAsync throws when no match and no default")] + [Fact] + public Task AsyncVisitor_Throws_When_NoMatch_NoDefault() + => Given("a visitor without default", () => + AsyncVisitor.Create().On((_, _) => new ValueTask(3)).Build()) + .When("calling VisitAsync on Neg", ExpectInvalidOp) + .Then("threw InvalidOperationException", threw => threw) + .AssertPassed(); + + private static async Task ExpectInvalidOp(AsyncVisitor v) + { + try + { + await v.VisitAsync(new Neg(new Number(0))); + return false; + } + catch (InvalidOperationException) + { + return true; + } + } + + [Scenario("Cancellation is observed by async handler")] + [Fact] + public Task AsyncVisitor_Cancellation_Propagates() + => Given("a visitor with cancellable handler", () => + AsyncVisitor.Create() + .On((n, ct) => { ct.ThrowIfCancellationRequested(); return new ValueTask(n.Value); }) + .Default((_, _) => new ValueTask(-1)) + .Build()) + .When("invoking with canceled token", CallWithCanceledToken) + .Then("throws OperationCanceledException", threw => threw) + .AssertPassed(); + + private static async Task CallWithCanceledToken(AsyncVisitor v) + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + try + { + await v.VisitAsync(new Number(5), cts.Token); + return false; + } + catch (OperationCanceledException) + { + return true; + } + } + + [Scenario("Sync default overload is honored")] + [Fact] + public Task AsyncVisitor_DefaultSyncOverload_Works() + => Given("visitor with sync default", () => + AsyncVisitor.Create() + .On((_, _) => new ValueTask(1)) + .Default(_ => -5) + .Build()) + .When("visiting Neg", VisitNegDefaultSync) + .Then("result is -5", r => r == -5) + .AssertPassed(); + + private static async Task VisitNegDefaultSync(AsyncVisitor v) + => await v.VisitAsync(new Neg(new Number(1))); +} diff --git a/test/PatternKit.Tests/Behavioral/Iterator/AsyncFlowTests.cs b/test/PatternKit.Tests/Behavioral/Iterator/AsyncFlowTests.cs index 8499335..0263766 100644 --- a/test/PatternKit.Tests/Behavioral/Iterator/AsyncFlowTests.cs +++ b/test/PatternKit.Tests/Behavioral/Iterator/AsyncFlowTests.cs @@ -11,7 +11,7 @@ public sealed class AsyncFlowTests(ITestOutputHelper output) : TinyBddXunitBase( { private static async IAsyncEnumerable RangeAsync(int count, int delayMs = 0) { - for (int i = 1; i <= count; i++) + for (var i = 1; i <= count; i++) { if (delayMs > 0) await Task.Delay(delayMs).ConfigureAwait(false); yield return i; diff --git a/test/PatternKit.Tests/Behavioral/Iterator/FlowTests.cs b/test/PatternKit.Tests/Behavioral/Iterator/FlowTests.cs index 54b6b33..456607e 100644 --- a/test/PatternKit.Tests/Behavioral/Iterator/FlowTests.cs +++ b/test/PatternKit.Tests/Behavioral/Iterator/FlowTests.cs @@ -16,7 +16,7 @@ private sealed class CountingEnumerable : IEnumerable public CountingEnumerable(int count) => _count = count; public IEnumerator GetEnumerator() { - for (int i = 0; i < _count; i++) + for (var i = 0; i < _count; i++) { MoveNextCalls++; yield return i + 1; // 1..n diff --git a/test/PatternKit.Tests/Behavioral/VisitorAdditionalTests.cs b/test/PatternKit.Tests/Behavioral/VisitorAdditionalTests.cs new file mode 100644 index 0000000..5a39509 --- /dev/null +++ b/test/PatternKit.Tests/Behavioral/VisitorAdditionalTests.cs @@ -0,0 +1,60 @@ +using PatternKit.Behavioral.Visitor; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Behavioral; + +[Feature("Behavioral - Visitor additional scenarios")] +public sealed class VisitorAdditionalTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + private abstract record Node; + private sealed record Number(int Value) : Node; + private sealed record Add(Node Left, Node Right) : Node; + private sealed record Neg(Node Inner) : Node; + + [Scenario("Visit throws when no match and no default")] + [Fact] + public Task ResultVisitor_Throws_NoDefault() + => Given("a visitor without default", () => + Visitor.Create().On(n => n.Value).Build()) + .When("visiting Neg", ExpectInvalidOp) + .Then("threw InvalidOperationException", threw => threw) + .AssertPassed(); + + private static bool ExpectInvalidOp(Visitor v) + { + try + { + v.Visit(new Neg(new Number(1))); + return false; + } + catch (InvalidOperationException) + { + return true; + } + } + + [Scenario("TryVisit returns true and default result when default configured")] + [Fact] + public Task ResultVisitor_TryVisit_Default_ReturnsTrue() + => Given("a visitor with default result", () => + Visitor.Create().Default(_ => "?").Build()) + .When("TryVisit Neg", v => { var ok = v.TryVisit(new Neg(new Number(0)), out var res); return (ok, res); }) + .Then("ok == true", x => x.ok) + .And("result == ?", x => x.res == "?") + .AssertPassed(); + + [Scenario("Registration order matters: base before derived (result)")] + [Fact] + public Task ResultVisitor_Order_Matters() + => Given("a visitor with base first", () => + Visitor.Create() + .On(_ => "base") + .On(_ => "number") + .Build()) + .When("visiting Number", v => v.Visit(new Number(9))) + .Then("base handled first", s => s == "base") + .AssertPassed(); +} + diff --git a/test/PatternKit.Tests/Behavioral/VisitorConcurrencyTests.cs b/test/PatternKit.Tests/Behavioral/VisitorConcurrencyTests.cs new file mode 100644 index 0000000..94ed974 --- /dev/null +++ b/test/PatternKit.Tests/Behavioral/VisitorConcurrencyTests.cs @@ -0,0 +1,66 @@ +using PatternKit.Behavioral.Visitor; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Behavioral; + +[Feature("Behavioral - Visitor Concurrency (Sync)")] +public sealed class VisitorConcurrencyTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + private sealed record State(ActionVisitor v, int[] counters, Node[] nodes, int perType); + + private abstract record Node; + private sealed record Number(int Value) : Node; + private sealed record Add(Node Left, Node Right) : Node; + private sealed record Neg(Node Inner) : Node; + + [Scenario("ActionVisitor executes correctly under concurrent calls across threads")] + [Fact] + public Task ActionVisitor_Concurrent_Dispatch() + => Given("an action visitor and a mixed dataset", () => + { + var counters = new int[3]; // [0]=Add, [1]=Number, [2]=Default + var v = ActionVisitor + .Create() + .On(_ => Interlocked.Increment(ref counters[0])) + .On(_ => Interlocked.Increment(ref counters[1])) + .Default(_ => Interlocked.Increment(ref counters[2])) + .Build(); + + const int perType = 2000; + var baseNodes = new Node[] { new Add(new Number(1), new Number(2)), new Number(7), new Neg(new Number(0)) }; + var nodes = new Node[perType * baseNodes.Length]; + for (var i = 0; i < perType; i++) + { + nodes[3 * i + 0] = baseNodes[0]; + nodes[3 * i + 1] = baseNodes[1]; + nodes[3 * i + 2] = baseNodes[2]; + } + + return new State(v, counters, nodes, perType); + }) + .When("visiting in parallel", VisitInParallel) + .Then("each action ran expected times", x => x.counters[0] == x.perType && x.counters[1] == x.perType && x.counters[2] == x.perType) + .AssertPassed(); + + private static async Task VisitInParallel(State x) + { + var degree = Math.Min(Environment.ProcessorCount, 8); + var total = x.nodes.Length; + var slice = total / degree; + var tasks = new List(degree); + for (var i = 0; i < degree; i++) + { + var start = i * slice; + var end = (i == degree - 1) ? total : start + slice; + tasks.Add(Task.Run(() => + { + for (var j = start; j < end; j++) + x.v.Visit(x.nodes[j]); + })); + } + await Task.WhenAll(tasks); + return x; + } +} diff --git a/test/PatternKit.Tests/Behavioral/VisitorResultConcurrencyTests.cs b/test/PatternKit.Tests/Behavioral/VisitorResultConcurrencyTests.cs new file mode 100644 index 0000000..3ef00d4 --- /dev/null +++ b/test/PatternKit.Tests/Behavioral/VisitorResultConcurrencyTests.cs @@ -0,0 +1,69 @@ +using PatternKit.Behavioral.Visitor; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Behavioral; + +[Feature("Behavioral - Visitor Concurrency (Result)")] +public sealed class VisitorResultConcurrencyTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + private sealed record State(Visitor v, int[] counters, Node[] nodes, int perType); + + private abstract record Node; + private sealed record Number(int Value) : Node; + private sealed record Add(Node Left, Node Right) : Node; + private sealed record Neg(Node Inner) : Node; + + [Scenario("Result Visitor returns correct codes under parallel calls across threads")] + [Fact] + public Task ResultVisitor_Concurrent_Dispatch() + => Given("a result visitor and a mixed dataset", () => + { + var counters = new int[3]; // [0]=Add, [1]=Number, [2]=Default + var v = Visitor + .Create() + .On(static _ => 0) + .On(static _ => 1) + .Default(static _ => 2) + .Build(); + + const int perType = 2000; + var baseNodes = new Node[] { new Add(new Number(1), new Number(2)), new Number(7), new Neg(new Number(0)) }; + var nodes = new Node[perType * baseNodes.Length]; + for (var i = 0; i < perType; i++) + { + nodes[3 * i + 0] = baseNodes[0]; + nodes[3 * i + 1] = baseNodes[1]; + nodes[3 * i + 2] = baseNodes[2]; + } + + return new State(v, counters, nodes, perType); + }) + .When("visiting in parallel and tallying results", VisitInParallel) + .Then("each code occurred expected times", x => x.counters[0] == x.perType && x.counters[1] == x.perType && x.counters[2] == x.perType) + .AssertPassed(); + + private static async Task VisitInParallel(State x) + { + var degree = Math.Min(Environment.ProcessorCount, 8); + var total = x.nodes.Length; + var slice = total / degree; + var tasks = new List(degree); + for (var i = 0; i < degree; i++) + { + var start = i * slice; + var end = (i == degree - 1) ? total : start + slice; + tasks.Add(Task.Run(() => + { + for (var j = start; j < end; j++) + { + var code = x.v.Visit(x.nodes[j]); + Interlocked.Increment(ref x.counters[code]); + } + })); + } + await Task.WhenAll(tasks); + return x; + } +} diff --git a/test/PatternKit.Tests/Behavioral/VisitorTests.cs b/test/PatternKit.Tests/Behavioral/VisitorTests.cs new file mode 100644 index 0000000..cdbcd89 --- /dev/null +++ b/test/PatternKit.Tests/Behavioral/VisitorTests.cs @@ -0,0 +1,73 @@ +using PatternKit.Behavioral.Visitor; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Behavioral; + +[Feature("Behavioral - Visitor and ActionVisitor")] +public sealed class VisitorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + private abstract record Node; + private sealed record Number(int Value) : Node; + private sealed record Add(Node Left, Node Right) : Node; + private sealed record Neg(Node Inner) : Node; + + [Scenario("Result visitor dispatches by runtime type; order matters; default used")] + [Fact] + public Task ResultVisitor_Dispatch_And_Default() + => Given("a result visitor for Node", () => + { + var v = Visitor + .Create() + .On(_ => "+") + .On(n => $"#{n.Value}") + .Default(_ => "?") + .Build(); + return v; + }) + .When("visiting three nodes", v => ( + a: v.Visit(new Add(new Number(1), new Number(2))), + b: v.Visit(new Number(7)), + c: v.Visit(new Neg(new Number(1))) // no match, hits default + )) + .Then("Add -> +", r => r.a == "+") + .And("Number -> #7", r => r.b == "#7") + .And("Neg -> ? (default)", r => r.c == "?") + .AssertPassed(); + + [Scenario("TryVisit returns false when no handler and no default")] + [Fact] + public Task ResultVisitor_TryVisit_NoDefault() + => Given("a visitor without default", () => + Visitor.Create().On(n => n.Value.ToString()).Build()) + .When("TryVisit Neg", v => v.TryVisit(new Neg(new Number(3)), out var res) ? res : null) + .Then("returns null (no match)", r => r is null) + .AssertPassed(); + + [Scenario("Action visitor executes side effects by runtime type")] + [Fact] + public Task ActionVisitor_Dispatch() + => Given("a counter and action visitor", () => + { + var counters = new int[3]; // [0]=Add, [1]=Number, [2]=Default + var v = ActionVisitor + .Create() + .On(_ => Interlocked.Increment(ref counters[0])) + .On(_ => Interlocked.Increment(ref counters[1])) + .Default(_ => Interlocked.Increment(ref counters[2])) + .Build(); + return (v, counters); + }) + .When("visit mixed nodes", x => + { + x.v.Visit(new Add(new Number(1), new Number(2))); + x.v.Visit(new Number(5)); + x.v.Visit(new Neg(new Number(9))); // default + return x.counters; + }) + .Then("Add handled once", c => c[0] == 1) + .And("Number handled once", c => c[1] == 1) + .And("Default handled once", c => c[2] == 1) + .AssertPassed(); +} From d02c4c3646f683f1fdd78f329e07f33d46810aef Mon Sep 17 00:00:00 2001 From: "Jerrett D. Davis" Date: Tue, 28 Oct 2025 22:12:12 -0500 Subject: [PATCH 2/2] feat: added pattern usage example. --- docs/examples/patterns-showcase.md | 109 +++++++ docs/examples/toc.yml | 2 + .../PatternShowcase/PatternShowcase.cs | 267 ++++++++++++++++++ src/PatternKit.Generators/packages.lock.json | 188 ------------ .../PatternShowcase/PatternShowcaseTests.cs | 75 +++++ 5 files changed, 453 insertions(+), 188 deletions(-) create mode 100644 docs/examples/patterns-showcase.md create mode 100644 src/PatternKit.Examples/PatternShowcase/PatternShowcase.cs create mode 100644 test/PatternKit.Examples.Tests/PatternShowcase/PatternShowcaseTests.cs diff --git a/docs/examples/patterns-showcase.md b/docs/examples/patterns-showcase.md new file mode 100644 index 0000000..02eee02 --- /dev/null +++ b/docs/examples/patterns-showcase.md @@ -0,0 +1,109 @@ +# Patterns Showcase — Integrated Order Processing + +This example composes many PatternKit patterns in a single, realistic flow: adapting an external order, selecting behavior, executing reversible operations, and coordinating notifications — all behind a simple facade. + +Source: `src/PatternKit.Examples/PatternShowcase/PatternShowcase.cs:1` + +--- + +## Scenario + +- Adapt external `OrderDto` to an internal `Order` model. +- Select discounts based on rules and apply payment fees by type. +- Execute a reversible command pipeline (reserve → charge → schedule) under a template. +- Publish audit/metric events and generate a receipt via mediator. +- Expose everything through a typed facade that’s easy to call from a controller/service. + +--- + +## Quick Start + +```csharp +// Build once at startup +var facade = PatternKit.Examples.PatternShowcase.PatternShowcase.Build(); + +// Incoming request → DTO +var dto = new PatternKit.Examples.PatternShowcase.PatternShowcase.OrderDto( + OrderId: "ORD-1001", + CustomerId: "VIP-42", + PaymentKind: "card", + Items: new [] { + new PatternKit.Examples.PatternShowcase.PatternShowcase.OrderItemDto("SKU-1","Widget", 49.99m, 2, "Promo") + }); + +var (ok, message, total) = facade.Place(dto); +// ok == true, message == "Order processed", total reflects discount + fees +``` + +--- + +## Pattern Map (Who Does What) + +- Adapter — `OrderDto` → `Order`: field mapping + validation. +- Factory — `IPaymentGateway` selection by key: `sandbox`, `stripe`, default. +- Strategy — Discount rules over `OrderContext` (first match wins). +- Visitor — Payment fee by runtime type (`Cash`, `Card`). +- Template — Algorithm skeleton: compute totals → execute commands → emit events/receipt. +- Command — Reversible steps: reserve inventory, charge payment, schedule shipment, composed as a macro. +- Mediator — Generate receipt and emit notifications (decoupled handlers/pre/post behaviors). +- Observer — Audit/metric event hub for subscribers. +- Facade — Typed API that hides the composition and exposes `Place(OrderDto)`. + +--- + +## Similarities And Differences + +- Visitor vs Strategy + - Similar: both route to behavior based on a condition. + - Different: Visitor dispatches by runtime type; Strategy dispatches by boolean predicates. Use Visitor when the type tells you the behavior; Strategy when rules are data/condition‑driven. + +- Adapter vs Facade + - Similar: both present a friendlier surface area. + - Different: Adapter transforms data shape; Facade aggregates operations behind a typed contract. Use Adapter at system boundaries, Facade to simplify internal orchestration. + +- Command vs Template + - Similar: both structure execution. + - Different: Command encapsulates a unit of work (with optional undo); Template defines a skeleton with hooks. Use Command for reversible steps and composition; Template for consistent flow with before/after/error hooks. + +- Mediator vs Observer + - Similar: decouple senders from receivers. + - Different: Mediator is request/response + pipeline behaviors; Observer is pub/sub broadcast. Use Mediator for commands/queries, Observer for fan‑out notifications. + +- Factory vs Visitor + - Similar: both pick a concrete implementation. + - Different: Factory chooses a product by key; Visitor chooses a handler by input type at runtime. Use Factory for creation, Visitor for behavior. + +--- + +## Best‑Fit Guidance + +- Type‑based behavior → Visitor (`Payment` handling). +- Rule‑based selection → Strategy (`OrderContext` discounts). +- Boundary transformation → Adapter (`OrderDto` to `Order`). +- Consistent flow with hooks → Template (compute totals, run commands, report). +- Reversible operations or macro steps → Command (with undo, macro composition). +- Creation by key/config → Factory (`IPaymentGateway`). +- Cross‑cutting orchestration → Mediator (receipt generation, behaviors). +- Broadcast events → Observer (AUDIT/METRIC). +- Simplified client API → Facade (`IOrderProcessingFacade`). + +--- + +## Code Pointers + +- Adapter: `BuildOrderAdapter` — validates `OrderId` and requires ≥1 item. +- Factory: `BuildPaymentFactory` — string key to `IPaymentGateway` mapping. +- Strategy: `BuildDiscountStrategy` — promo category or VIP id. +- Visitor: `BuildFeeVisitor` — 2.9% card fee with min $0.30, $0 for cash. +- Template + Commands: `BuildTemplatePipeline` — reserve → charge → schedule; error hook appends to audit. +- Mediator + Observer: `BuildMediatorAndEvents` — receipt command + AUDIT publish. +- Facade: `Build()` — wires everything together. + +--- + +## Extending The Showcase + +- Add fraud checks as a `Strategy` to gate the command pipeline. +- Make shipping asynchronous via `AsyncActionVisitor` or mediator streams. +- Snapshot/restore the context with `Memento` when you need full state time‑travel beyond command undo. + diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index a7c47b7..20aa962 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -40,6 +40,8 @@ - name: Visitor — Message Router (Background Worker) href: message-router-visitor.md +- name: Patterns Showcase — Integrated Order Processing + href: patterns-showcase.md - name: Proxy Pattern Demonstrations — Virtual, Protection, Caching, Logging, Mocking, Remote href: proxy-demo.md diff --git a/src/PatternKit.Examples/PatternShowcase/PatternShowcase.cs b/src/PatternKit.Examples/PatternShowcase/PatternShowcase.cs new file mode 100644 index 0000000..69ccf11 --- /dev/null +++ b/src/PatternKit.Examples/PatternShowcase/PatternShowcase.cs @@ -0,0 +1,267 @@ +using PatternKit.Behavioral.Command; +using PatternKit.Behavioral.Mediator; +using PatternKit.Behavioral.Observer; +using PatternKit.Behavioral.Strategy; +using PatternKit.Behavioral.Template; +using PatternKit.Behavioral.Visitor; +using PatternKit.Creational.Factory; +using PatternKit.Structural.Adapter; +using PatternKit.Structural.Facade; + +namespace PatternKit.Examples.PatternShowcase; + +/// +/// End-to-end demonstration that composes many patterns to process an order. +/// Shows how Strategy, Factory, Adapter, Template Method, Command (with undo), +/// Visitor, Mediator, Observer, Memento (via Command undo), and Facade can work together. +/// +public static class PatternShowcase +{ + // -------- Domain DTOs (external) and Models (internal) -------- + + /// External API DTO for an incoming order. + public sealed record OrderDto(string OrderId, string CustomerId, string PaymentKind, OrderItemDto[] Items); + + /// External API DTO for a line item. + public sealed record OrderItemDto(string Sku, string Name, decimal Price, int Qty, string? Category = null); + + /// Internal order aggregate. + public sealed class Order + { + public required string OrderId { get; set; } + public required string CustomerId { get; set; } + public required Payment Payment { get; set; } + public required List Items { get; set; } + } + + /// Internal line item. + public sealed record OrderItem(string Sku, string Name, decimal UnitPrice, int Quantity, string? Category = null); + + /// Payment base type and variants for visitor-based dispatch. + public abstract record Payment; + public sealed record Cash(decimal Amount) : Payment; + public sealed record Card(string Brand, string Last4, decimal Amount) : Payment; + + /// Runtime order processing context used by commands and template. + public sealed class OrderContext + { + public required Order Order { get; init; } + public required IPaymentGateway Gateway { get; init; } + public decimal Subtotal { get; set; } + public decimal Discount { get; set; } + public decimal Fees { get; set; } + public decimal Total => Subtotal - Discount + Fees; + public List Audit { get; } = new(); + } + + // -------- Facade Contract -------- + + /// + /// Typed facade exposing a minimal, task-focused API for clients to place orders. + /// Implemented using for strong typing. + /// + public interface IOrderProcessingFacade + { + (bool ok, string message, decimal total) Place(OrderDto dto); + } + + /// + /// Entry point to build the facade instance. Consumers can register this in DI as a singleton. + /// + public static IOrderProcessingFacade Build() + { + // 1) Adapter: external DTO → internal model + var dtoToOrder = BuildOrderAdapter(); + + // 2) Factory: pick payment gateway by key + var gatewayFactory = BuildPaymentFactory(); + + // 3) Strategy: compute discounts by customer/category + var discount = BuildDiscountStrategy(); + + // 4) Visitor: compute payment fees by runtime payment type + var feeCalc = BuildFeeVisitor(); + + // 5) Mediator + Observer: orchestration/advisory signals + var (mediator, events) = BuildMediatorAndEvents(); + + // 6) Template + Command: algorithm skeleton and reversible steps + var pipeline = BuildTemplatePipeline(discount, feeCalc, mediator, events); + + // 7) Facade: adapt → compose gateway → run template + return TypedFacade.Create() + .Map(x => x.Place, (OrderDto dto) => + { + // Adapt + if (!dtoToOrder.TryAdapt(dto, out var order, out var err)) + return (false, err ?? "Adapt failed", 0m); + + // Factory + var gateway = gatewayFactory.Create(KeyFrom(dto.PaymentKind)); + + // Compose context + var ctx = new OrderContext { Order = order, Gateway = gateway }; + + // Template execution + var ok = pipeline.TryExecute(ctx, out _, out var error); + var msg = ok ? "Order processed" : error ?? "Error"; + return (ok, msg, ctx.Total); + }) + .Build(); + } + + private static string KeyFrom(string kind) + => string.IsNullOrWhiteSpace(kind) ? "default" : kind.Trim().ToLowerInvariant(); + + // -------- Pattern Builders -------- + + /// Adapter that maps to an with payment subtypes. + private static Adapter BuildOrderAdapter() + => Adapter.Create(seed: () => new Order { OrderId = "", CustomerId = "", Payment = new Cash(0m), Items = new() }) + .Map(static (in src, dest) => dest.OrderId = src.OrderId) + .Map(static (in src, dest) => dest.CustomerId = src.CustomerId) + .Map(static (in src, dest) => dest.Payment = src.PaymentKind.ToLowerInvariant() switch + { + "cash" => new Cash(src.Items.Sum(i => i.Price * i.Qty)), + "card" => new Card("VISA", "4242", src.Items.Sum(i => i.Price * i.Qty)), + _ => new Cash(src.Items.Sum(i => i.Price * i.Qty)) + }) + .Map(static (in src, dest) => dest.Items = src.Items.Select(i => new OrderItem(i.Sku, i.Name, i.Price, i.Qty, i.Category)).ToList()) + .Require(static (in src, _) => string.IsNullOrWhiteSpace(src.OrderId) ? "OrderId required" : null) + .Require(static (in _, dest) => dest.Items.Count == 0 ? "At least one item is required" : null) + .Build(); + + /// Factory that returns a payment gateway implementation by string key. + private static Factory BuildPaymentFactory() + => Factory.Create(StringComparer.OrdinalIgnoreCase) + .Map("sandbox", () => new SandboxGateway()) + .Map("stripe", () => new StripeGateway()) + .Default(() => new SandboxGateway()) + .Build(); + + /// Strategy that computes order discount based on simple rules. + private static Strategy BuildDiscountStrategy() + => Strategy.Create() + .When(static (in inCtx) => inCtx.Order.Items.Any(i => string.Equals(i.Category, "Promo", StringComparison.OrdinalIgnoreCase))) + .Then(static (in inCtx) => inCtx.Subtotal * 0.10m) // 10% off for promo category presence + .When(static (in inCtx) => inCtx.Order.CustomerId.StartsWith("VIP", StringComparison.OrdinalIgnoreCase)) + .Then(static (in _) => 15m) // VIP flat discount + .Default(static (in _) => 0m) + .Build(); + + /// Visitor that calculates fees for different payment types. + private static Visitor BuildFeeVisitor() + => Visitor.Create() + .On(static _ => 0m) + .On(static c => Math.Max(0.30m, Math.Round(c.Amount * 0.029m, 2))) + .Default(static _ => 0m) + .Build(); + + /// Set up mediator and observer used for orchestration and audit. + private static (Mediator mediator, Observer events) BuildMediatorAndEvents() + { + var events = Observer.Create().ThrowAggregate().Build(); + + var mediator = Mediator.Create() + .Pre(static (in _, _) => { /* global validation/logging */ return default; }) + .Command(static (in r, _) => new ValueTask($"Receipt for {r.OrderId}: ${r.Total:F2}")) + .Notification((in n, _) => { events.Publish($"AUDIT: order placed {n.OrderId}"); return default; }) + .Build(); + + return (mediator, events); + } + + /// Template pipeline that computes totals and executes reversible operations via commands. + private static Template BuildTemplatePipeline( + Strategy discount, + Visitor feeCalc, + Mediator mediator, + Observer events) + { + // Commands (each with optional undo) + var reserveInventory = Command.Create() + .Do(static ctx => { ctx.Audit.Add("Reserve OK"); }) + .Undo(static ctx => { ctx.Audit.Add("Reserve UNDO"); }) + .Build(); + + var chargePayment = Command.Create() + .Do(static (in ctx, ct) => Charge(ctx, ct)) + .Undo(static (in ctx, ct) => Refund(ctx, ct)) + .Build(); + + var scheduleShipment = Command.Create() + .Do(static ctx => { ctx.Audit.Add("Shipment scheduled"); }) + .Undo(static ctx => { ctx.Audit.Add("Shipment cancelled"); }) + .Build(); + + var macro = Command.Macro() + .Add(reserveInventory) + .Add(chargePayment) + .Add(scheduleShipment) + .Build(); + + // Template step computes totals, executes macro, and returns receipt text via mediator + return Template.Create(ctx => + { + ctx.Subtotal = ctx.Order.Items.Sum(i => i.UnitPrice * i.Quantity); + ctx.Discount = discount.Execute(in ctx); + ctx.Fees = feeCalc.Visit(ctx.Order.Payment); + + // Run macro (throws propagate) + macro.Execute(in ctx); + + // Publish event and generate receipt via mediator + events.Publish($"METRIC: total={ctx.Total:F2}"); + var receipt = mediator.Send(new GenerateReceipt(ctx.Order.OrderId, ctx.Total)).GetAwaiter().GetResult(); + return receipt ?? string.Empty; + }) + .Before(static ctx => ctx.Audit.Add("BEGIN")) + .After(static (ctx, _) => ctx.Audit.Add("END")) + .OnError(static (ctx, err) => ctx.Audit.Add($"ERROR: {err}")) + .Build(); + } + + // Async helpers to avoid 'async in' lambdas + private static async ValueTask Charge(OrderContext ctx, CancellationToken ct) + { + await ctx.Gateway.ChargeAsync(ctx.Total, ct); + ctx.Audit.Add("Charged"); + } + + private static async ValueTask Refund(OrderContext ctx, CancellationToken ct) + { + await ctx.Gateway.RefundAsync(ctx.Total, ct); + ctx.Audit.Add("Refunded"); + } + + // -------- Support types used by Mediator -------- + + /// Notification emitted when an order is placed. + public readonly record struct OrderPlaced(string OrderId); + + /// Command request to generate a simple receipt line. + public readonly record struct GenerateReceipt(string OrderId, decimal Total); + + // -------- Payment gateway abstractions & impls -------- + + /// Payment gateway abstraction used by the example commands. + public interface IPaymentGateway + { + ValueTask ChargeAsync(decimal amount, CancellationToken ct); + ValueTask RefundAsync(decimal amount, CancellationToken ct); + } + + /// Sandbox gateway that only logs actions. + public sealed class SandboxGateway : IPaymentGateway + { + public ValueTask ChargeAsync(decimal amount, CancellationToken ct) { return default; } + public ValueTask RefundAsync(decimal amount, CancellationToken ct) { return default; } + } + + /// Pretend Stripe gateway with the same interface. + public sealed class StripeGateway : IPaymentGateway + { + public ValueTask ChargeAsync(decimal amount, CancellationToken ct) { return default; } + public ValueTask RefundAsync(decimal amount, CancellationToken ct) { return default; } + } +} diff --git a/src/PatternKit.Generators/packages.lock.json b/src/PatternKit.Generators/packages.lock.json index 40a2a74..4eb9db9 100644 --- a/src/PatternKit.Generators/packages.lock.json +++ b/src/PatternKit.Generators/packages.lock.json @@ -117,194 +117,6 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.3" } } - }, - ".NETStandard,Version=v2.1": { - "Microsoft.CodeAnalysis.Analyzers": { - "type": "Direct", - "requested": "[3.11.0, )", - "resolved": "3.11.0", - "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" - }, - "Microsoft.CodeAnalysis.CSharp": { - "type": "Direct", - "requested": "[4.14.0, )", - "resolved": "4.14.0", - "contentHash": "568a6wcTivauIhbeWcCwfWwIn7UV7MeHEBvFB2uzGIpM2OhJ4eM/FZ8KS0yhPoNxnSpjGzz7x7CIjTxhslojQA==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.Common": "[4.14.0]", - "System.Buffers": "4.5.1", - "System.Collections.Immutable": "9.0.0", - "System.Memory": "4.5.5", - "System.Numerics.Vectors": "4.5.0", - "System.Reflection.Metadata": "9.0.0", - "System.Runtime.CompilerServices.Unsafe": "6.0.0", - "System.Text.Encoding.CodePages": "7.0.0", - "System.Threading.Tasks.Extensions": "4.5.4" - } - }, - "System.Collections.Immutable": { - "type": "Direct", - "requested": "[9.0.9, )", - "resolved": "9.0.9", - "contentHash": "/kpkgDxH984e3J3z5v/DIFi+0TWbUJXS8HNKUYBy3YnXtK09JVGs3cw5aOV6fDSw5NxbWLWlGrYjRteu6cjX3w==", - "dependencies": { - "System.Memory": "4.5.5", - "System.Runtime.CompilerServices.Unsafe": "6.0.0" - } - }, - "Microsoft.CodeAnalysis.Common": { - "type": "Transitive", - "resolved": "4.14.0", - "contentHash": "PC3tuwZYnC+idaPuoC/AZpEdwrtX7qFpmnrfQkgobGIWiYmGi5MCRtl5mx6QrfMGQpK78X2lfIEoZDLg/qnuHg==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "System.Buffers": "4.5.1", - "System.Collections.Immutable": "9.0.0", - "System.Memory": "4.5.5", - "System.Numerics.Vectors": "4.5.0", - "System.Reflection.Metadata": "9.0.0", - "System.Runtime.CompilerServices.Unsafe": "6.0.0", - "System.Text.Encoding.CodePages": "7.0.0", - "System.Threading.Tasks.Extensions": "4.5.4" - } - }, - "System.Buffers": { - "type": "Transitive", - "resolved": "4.5.1", - "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" - }, - "System.Memory": { - "type": "Transitive", - "resolved": "4.5.5", - "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", - "dependencies": { - "System.Buffers": "4.5.1", - "System.Numerics.Vectors": "4.4.0", - "System.Runtime.CompilerServices.Unsafe": "4.5.3" - } - }, - "System.Numerics.Vectors": { - "type": "Transitive", - "resolved": "4.5.0", - "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" - }, - "System.Reflection.Metadata": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "ANiqLu3DxW9kol/hMmTWbt3414t9ftdIuiIU7j80okq2YzAueo120M442xk1kDJWtmZTqWQn7wHDvMRipVOEOQ==", - "dependencies": { - "System.Collections.Immutable": "9.0.0", - "System.Memory": "4.5.5" - } - }, - "System.Runtime.CompilerServices.Unsafe": { - "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" - }, - "System.Text.Encoding.CodePages": { - "type": "Transitive", - "resolved": "7.0.0", - "contentHash": "LSyCblMpvOe0N3E+8e0skHcrIhgV2huaNcjUUEa8hRtgEAm36aGkRoC8Jxlb6Ra6GSfF29ftduPNywin8XolzQ==", - "dependencies": { - "System.Memory": "4.5.5", - "System.Runtime.CompilerServices.Unsafe": "6.0.0" - } - }, - "System.Threading.Tasks.Extensions": { - "type": "Transitive", - "resolved": "4.5.4", - "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", - "dependencies": { - "System.Runtime.CompilerServices.Unsafe": "4.5.3" - } - } - }, - "net8.0": { - "Microsoft.CodeAnalysis.Analyzers": { - "type": "Direct", - "requested": "[3.11.0, )", - "resolved": "3.11.0", - "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" - }, - "Microsoft.CodeAnalysis.CSharp": { - "type": "Direct", - "requested": "[4.14.0, )", - "resolved": "4.14.0", - "contentHash": "568a6wcTivauIhbeWcCwfWwIn7UV7MeHEBvFB2uzGIpM2OhJ4eM/FZ8KS0yhPoNxnSpjGzz7x7CIjTxhslojQA==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.Common": "[4.14.0]", - "System.Collections.Immutable": "9.0.0", - "System.Reflection.Metadata": "9.0.0" - } - }, - "System.Collections.Immutable": { - "type": "Direct", - "requested": "[9.0.9, )", - "resolved": "9.0.9", - "contentHash": "/kpkgDxH984e3J3z5v/DIFi+0TWbUJXS8HNKUYBy3YnXtK09JVGs3cw5aOV6fDSw5NxbWLWlGrYjRteu6cjX3w==" - }, - "Microsoft.CodeAnalysis.Common": { - "type": "Transitive", - "resolved": "4.14.0", - "contentHash": "PC3tuwZYnC+idaPuoC/AZpEdwrtX7qFpmnrfQkgobGIWiYmGi5MCRtl5mx6QrfMGQpK78X2lfIEoZDLg/qnuHg==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "System.Collections.Immutable": "9.0.0", - "System.Reflection.Metadata": "9.0.0" - } - }, - "System.Reflection.Metadata": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "ANiqLu3DxW9kol/hMmTWbt3414t9ftdIuiIU7j80okq2YzAueo120M442xk1kDJWtmZTqWQn7wHDvMRipVOEOQ==", - "dependencies": { - "System.Collections.Immutable": "9.0.0" - } - } - }, - "net9.0": { - "Microsoft.CodeAnalysis.Analyzers": { - "type": "Direct", - "requested": "[3.11.0, )", - "resolved": "3.11.0", - "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" - }, - "Microsoft.CodeAnalysis.CSharp": { - "type": "Direct", - "requested": "[4.14.0, )", - "resolved": "4.14.0", - "contentHash": "568a6wcTivauIhbeWcCwfWwIn7UV7MeHEBvFB2uzGIpM2OhJ4eM/FZ8KS0yhPoNxnSpjGzz7x7CIjTxhslojQA==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.Common": "[4.14.0]", - "System.Collections.Immutable": "9.0.0", - "System.Reflection.Metadata": "9.0.0" - } - }, - "System.Collections.Immutable": { - "type": "Direct", - "requested": "[9.0.9, )", - "resolved": "9.0.9", - "contentHash": "/kpkgDxH984e3J3z5v/DIFi+0TWbUJXS8HNKUYBy3YnXtK09JVGs3cw5aOV6fDSw5NxbWLWlGrYjRteu6cjX3w==" - }, - "Microsoft.CodeAnalysis.Common": { - "type": "Transitive", - "resolved": "4.14.0", - "contentHash": "PC3tuwZYnC+idaPuoC/AZpEdwrtX7qFpmnrfQkgobGIWiYmGi5MCRtl5mx6QrfMGQpK78X2lfIEoZDLg/qnuHg==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "System.Collections.Immutable": "9.0.0", - "System.Reflection.Metadata": "9.0.0" - } - }, - "System.Reflection.Metadata": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "ANiqLu3DxW9kol/hMmTWbt3414t9ftdIuiIU7j80okq2YzAueo120M442xk1kDJWtmZTqWQn7wHDvMRipVOEOQ==" - } } } } \ No newline at end of file diff --git a/test/PatternKit.Examples.Tests/PatternShowcase/PatternShowcaseTests.cs b/test/PatternKit.Examples.Tests/PatternShowcase/PatternShowcaseTests.cs new file mode 100644 index 0000000..cbc372b --- /dev/null +++ b/test/PatternKit.Examples.Tests/PatternShowcase/PatternShowcaseTests.cs @@ -0,0 +1,75 @@ +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; +using Showcase = PatternKit.Examples.PatternShowcase.PatternShowcase; + +namespace PatternKit.Examples.Tests.PatternShowcase; + +[Feature("Patterns Showcase - Integrated Order Processing (TinyBDD)")] +public sealed class PatternShowcaseTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + private sealed record State(Showcase.IOrderProcessingFacade Facade, Showcase.OrderDto Dto); + + [Scenario("Card payment with promo items and VIP customer applies percent discount and card fee")] + [Fact] + public Task PlaceOrder_Card_Promo_Vip() + => Given("facade and a card order with promo items", + () => new State( + Showcase.Build(), + new Showcase.OrderDto( + OrderId: "ORD-1001", + CustomerId: "VIP-42", + PaymentKind: "card", + Items: + [ + new Showcase.OrderItemDto("SKU-1","Widget", 100.00m, 2, "Promo"), + new Showcase.OrderItemDto("SKU-2","Addon", 50.00m, 1) + ]))) + .When("placing the order", Place) + .Then("ok == true", r => r.ok) + .And("message is Order processed", r => r.message == "Order processed") + .And("total includes 10% promo discount and 2.9% card fee", + r => r.total == 232.25m) // subtotal=250, discount=25, fee=7.25 → total=232.25 + .AssertPassed(); + + [Scenario("Cash payment with VIP customer applies flat discount and no fee")] + [Fact] + public Task PlaceOrder_Cash_Vip_NoPromo() + => Given("facade and a cash order without promo items", + () => new State( + Showcase.Build(), + new Showcase.OrderDto( + OrderId: "ORD-2002", + CustomerId: "VIP-99", + PaymentKind: "cash", + Items: + [ + new Showcase.OrderItemDto("X","Thing", 80.00m, 1), + new Showcase.OrderItemDto("Y","Other", 120.00m, 1) + ]))) + .When("placing the order", Place) + .Then("ok == true", r => r.ok) + .And("total equals subtotal minus VIP flat 15", + r => r.total == 185.00m) // subtotal=200, discount=15, fee=0 + .AssertPassed(); + + [Scenario("Missing required fields fails adaptation with clear message")] + [Fact] + public Task PlaceOrder_Validation_Failure() + => Given("facade and an invalid order (missing id)", + () => new State( + Showcase.Build(), + new Showcase.OrderDto( + OrderId: "", + CustomerId: "C-1", + PaymentKind: "card", + Items: [new Showcase.OrderItemDto("S","N", 10m, 1)]))) + .When("placing the order", Place) + .Then("ok == false", r => r.ok == false) + .And("validation message is returned", r => r.message.Contains("OrderId required", StringComparison.Ordinal)) + .AssertPassed(); + + private static (bool ok, string message, decimal total) Place(State s) + => s.Facade.Place(s.Dto); +} +