diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f91370..c3f1995 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,11 +37,11 @@ jobs: dotnet tool restore - name: Build (Release) - run: dotnet build PatternKit.sln --configuration Release --no-restore /p:ContinuousIntegrationBuild=true + run: dotnet build PatternKit.slnx --configuration Release --no-restore /p:ContinuousIntegrationBuild=true - name: Test with coverage run: | - dotnet test PatternKit.sln \ + dotnet test PatternKit.slnx \ --configuration Release \ --collect:"XPlat Code Coverage" \ -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura \ @@ -139,7 +139,7 @@ jobs: - name: Build (Release) run: > - dotnet build PatternKit.sln + dotnet build PatternKit.slnx --configuration Release --no-restore /p:ContinuousIntegrationBuild=true @@ -150,7 +150,7 @@ jobs: - name: Test with coverage (Release) run: | - dotnet test PatternKit.sln \ + dotnet test PatternKit.slnx \ --configuration Release \ --collect:"XPlat Code Coverage" \ -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura \ @@ -160,7 +160,7 @@ jobs: - name: Pack (all packable projects) run: > - dotnet pack PatternKit.sln + dotnet pack PatternKit.slnx --configuration Release --no-build --output ./artifacts diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 3bc0eff..bf595c0 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -36,10 +36,10 @@ jobs: languages: csharp - name: Restore - run: dotnet restore PatternKit.sln --use-lock-file + run: dotnet restore PatternKit.slnx --use-lock-file - name: Build - run: dotnet build PatternKit.sln --configuration Release --no-restore + run: dotnet build PatternKit.slnx --configuration Release --no-restore - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 diff --git a/.gitignore b/.gitignore index 9f2efde..57e8bba 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ riderModule.iml .idea/ _site/ api/ -*.user \ No newline at end of file +*.user +TestResults/ \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index 7d5ba3d..bae549a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,12 +1,51 @@ - net9.0 + netstandard2.0;netstandard2.1;net8.0;net9.0 enable enable - latest + preview + true true true true $(NoWarn);1591 + + + Jerrett Davis and contributors + JDH Productions LLC. + PatternKit + PatternKit is a collection of design patterns implemented in a fluent API style for .NET, enabling developers to easily integrate common design patterns into their applications with readable and maintainable code. + design-patterns;fluent;fluent-api;dotnet; + + MIT + + git + https://github.com/JerrettDavis/PatternKit + https://github.com/JerrettDavis/PatternKit + + README.md + tiny.png + + true + snupkg + true + true + + true + + + + false + false + true + + + + + + + diff --git a/PatternKit.Core/PatternKit.Core.csproj b/PatternKit.Core/PatternKit.Core.csproj deleted file mode 100644 index 2accc01..0000000 --- a/PatternKit.Core/PatternKit.Core.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - - net9.0 - latest - enable - enable - PatternKit - - - diff --git a/PatternKit.sln b/PatternKit.sln deleted file mode 100644 index 55e55ef..0000000 --- a/PatternKit.sln +++ /dev/null @@ -1,35 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PatternKit.Core", "PatternKit.Core\PatternKit.Core.csproj", "{467854B0-1662-4C93-A79C-2AFE61D5CB5E}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PatternKit.Examples", "src\PatternKit.Examples\PatternKit.Examples.csproj", "{80469DAB-D194-4093-A13F-314B39B01DBC}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PatternKit.Tests", "test\PatternKit.Tests\PatternKit.Tests.csproj", "{FAAB375A-8E06-4943-BF8B-136CB5D11A9B}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{6C57A958-E01A-4D6F-BA17-0F95CCAC312E}" - ProjectSection(SolutionItems) = preProject - .gitignore = .gitignore - README.md = README.md - Directory.Build.props = Directory.Build.props - EndProjectSection -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {467854B0-1662-4C93-A79C-2AFE61D5CB5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {467854B0-1662-4C93-A79C-2AFE61D5CB5E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {467854B0-1662-4C93-A79C-2AFE61D5CB5E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {467854B0-1662-4C93-A79C-2AFE61D5CB5E}.Release|Any CPU.Build.0 = Release|Any CPU - {80469DAB-D194-4093-A13F-314B39B01DBC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {80469DAB-D194-4093-A13F-314B39B01DBC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {80469DAB-D194-4093-A13F-314B39B01DBC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {80469DAB-D194-4093-A13F-314B39B01DBC}.Release|Any CPU.Build.0 = Release|Any CPU - {FAAB375A-8E06-4943-BF8B-136CB5D11A9B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FAAB375A-8E06-4943-BF8B-136CB5D11A9B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FAAB375A-8E06-4943-BF8B-136CB5D11A9B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FAAB375A-8E06-4943-BF8B-136CB5D11A9B}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal diff --git a/PatternKit.slnx b/PatternKit.slnx new file mode 100644 index 0000000..d866dc0 --- /dev/null +++ b/PatternKit.slnx @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 00ff445..ea60f3e 100644 --- a/README.md +++ b/README.md @@ -64,11 +64,11 @@ if (parse.Execute("123", out var n)) PatternKit will grow to cover **Creational**, **Structural**, and **Behavioral** patterns with fluent, discoverable APIs: -| Category | Patterns ✓ = implemented | -| -------------- |-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **Creational** | Factory (planned) • Builder (planned) • Prototype (planned) • Singleton (planned) | -| **Structural** | Adapter (planned) • Bridge (planned) • Composite (planned) • Decorator (planned) • Facade (planned) • Flyweight (planned) • Proxy (planned) | -| **Behavioral** | Strategy ✓ • TryStrategy ✓ • Chain of Responsibility (planned) • Command (planned) • Iterator (planned) • Mediator (planned) • Memento (planned) • Observer (planned) • State (planned) • Template Method (planned) • Visitor (planned) | +| Category | Patterns ✓ = implemented | +| -------------- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Creational** | Factory (planned) • Builder (planned) • Prototype (planned) • Singleton (planned) | +| **Structural** | Adapter (planned) • Bridge (planned) • Composite (planned) • Decorator (planned) • Facade (planned) • Flyweight (planned) • Proxy (planned) | +| **Behavioral** | Strategy ✓ • TryStrategy ✓ • ActionStrategy ✓ • Chain of Responsibility (planned) • Command (planned) • Iterator (planned) • Mediator (planned) • Memento (planned) • Observer (planned) • State (planned) • Template Method (planned) • Visitor (planned) | Each pattern will ship with: @@ -163,3 +163,4 @@ PatternKit is inspired by: * Fluent APIs from **ASP.NET Core**, **System.Linq**, and modern libraries * The desire to make patterns **readable**, **performant**, and **fun** to use in 2025+ + diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..1e3d01f --- /dev/null +++ b/codecov.yml @@ -0,0 +1,3 @@ +ignore: + - "**/*.Tests/**" + - "**/*Tests*/**" diff --git a/docs/examples/auth-logging-chain.md b/docs/examples/auth-logging-chain.md new file mode 100644 index 0000000..ab3cc4b --- /dev/null +++ b/docs/examples/auth-logging-chain.md @@ -0,0 +1,162 @@ +# Auth & Logging with `ActionChain` + +A tiny example that shows how to use **PatternKit.Behavioral.Chain** to build request-logging and auth checks with **no `if`/`else`** and **first-match-wins** semantics. + +* **Goal:** log a request id when present, short-circuit unauthorized `/admin/*` requests, and (otherwise) log the method/path. +* **Key idea:** branchless pipelines using `When(...).ThenContinue(...)`, `When(...).ThenStop(...)`, and a `Finally(...)` tail. + +--- + +## The demo pipeline + +```csharp +using PatternKit.Behavioral.Chain; + +public readonly record struct HttpRequest(string Method, string Path, IReadOnlyDictionary Headers); +public readonly record struct HttpResponse(int Status, string Body); + +public static class AuthLoggingDemo +{ + public static List Run() + { + var log = new List(); + + var chain = ActionChain.Create() + // 1) request id (continue) + .When(static (in r) => r.Headers.ContainsKey("X-Request-Id")) + .ThenContinue(r => log.Add($"reqid={r.Headers["X-Request-Id"]}")) + + // 2) admin requires auth (stop) + .When(static (in r) => r.Path.StartsWith("/admin", StringComparison.Ordinal) + && !r.Headers.ContainsKey("Authorization")) + .ThenStop(_ => log.Add("deny: missing auth")) + + // 3) tail – runs only if the chain did not stop + .Finally((in r, next) => + { + log.Add($"{r.Method} {r.Path}"); + next(r); // terminal next is a no-op + }) + .Build(); + + // simulate + chain.Execute(new HttpRequest("GET", "/health", new Dictionary())); + chain.Execute(new HttpRequest("GET", "/admin/metrics", new Dictionary())); + + return log; + } +} +``` + +### What it logs (strict-stop semantics) + +`Run()` returns: + +``` +GET /health +deny: missing auth +``` + +Why not a third line (`GET /admin/metrics`)? Because **`.ThenStop(...)` halts the pipeline** and the `Finally(...)` tail does **not** run after a stop. That’s by design—great for auth short-circuits. + +--- + +## Mental model + +* **First match wins**: the first `When(...)` whose predicate is `true` executes its `Then...` and the others are skipped. +* **`.ThenContinue(...)`**: perform side effects and continue evaluating later steps (and eventually `Finally`). +* **`.ThenStop(...)`**: perform side effects and **end the pipeline immediately** (no `Finally`). +* **`Finally(...)`**: tail step that runs **only if the chain didn’t stop**. + +--- + +## Variant: “Always log method/path” + +If you want method/path to be logged **even when denied**, move that logging up front with `Use(...)`: + +```csharp +var chain = ActionChain.Create() + .Use((in r, next) => { log.Add($"{r.Method} {r.Path}"); next(r); }) + .When(static (in r) => r.Headers.ContainsKey("X-Request-Id")) + .ThenContinue(r => log.Add($"reqid={r.Headers["X-Request-Id"]}")) + .When(static (in r) => r.Path.StartsWith("/admin", StringComparison.Ordinal) + && !r.Headers.ContainsKey("Authorization")) + .ThenStop(_ => log.Add("deny: missing auth")) + .Build(); +``` + +Now the simulated run yields: + +``` +GET /health +GET /admin/metrics +deny: missing auth +``` + +(Logging happens before the deny short-circuit.) + +--- + +## TinyBDD smoke tests + +Here’s a compact set that locks in the strict-stop behavior (no method/path after deny): + +```csharp +using TinyBDD; +using TinyBDD.Xunit; +using Xunit; +using Xunit.Abstractions; + +[Feature("Auth & Logging demo (ActionChain)")] +public sealed class AuthLoggingDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Run() logs health, then denies admin without trailing method/path")] + [Fact] + public async Task Demo_Run_Smoke() + { + await Given("the demo Run() helper", () => (Func>)AuthLoggingDemo.Run) + .When("running it", run => run()) + .Then("first line is GET /health", log => log.ElementAtOrDefault(0) == "GET /health") + .And("second line is deny", log => log.ElementAtOrDefault(1) == "deny: missing auth") + .And("stops after deny (no third line)", log => log.Count == 2) + .AssertPassed(); + } + + [Scenario("Order with both: X-Request-Id then deny (no method/path)")] + [Fact] + public async Task RequestId_Then_Deny() + { + var log = new List(); + var chain = ActionChain.Create() + .When(static (in r) => r.Headers.ContainsKey("X-Request-Id")) + .ThenContinue(r => log.Add($"reqid={r.Headers["X-Request-Id"]}")) + .When(static (in r) => r.Path.StartsWith("/admin") && !r.Headers.ContainsKey("Authorization")) + .ThenStop(_ => log.Add("deny: missing auth")) + .Finally((in r, next) => { log.Add($"{r.Method} {r.Path}"); next(r); }) + .Build(); + + await Given("the chain and log", () => (chain, log)) + .When("GET /admin/x with X-Request-Id and no auth", t => + { + var (c, l) = t; + c.Execute(new HttpRequest("GET", "/admin/x", new Dictionary{{"X-Request-Id","rid-7"}})); + return l; + }) + .Then("reqid first", l => l.ElementAtOrDefault(0) == "reqid=rid-7") + .And("deny second", l => l.ElementAtOrDefault(1) == "deny: missing auth") + .And("no method/path after stop", l => l.Count == 2) + .AssertPassed(); + } +} +``` + +> Tip: use `ElementAtOrDefault` in tests to avoid index exceptions and get clearer failure messages. + +--- + +## When to use this pattern + +* **Middleware-like** concerns where some steps are pure side effects (logging, metrics, request-id) and others **short-circuit** (auth, feature gates). +* Places where you want **declarative ordering** and **no nested conditionals**—add/remove rules without touching the rest. + +That’s it—simple, predictable, and production-friendly. diff --git a/docs/examples/coercer.md b/docs/examples/coercer.md new file mode 100644 index 0000000..ef06eca --- /dev/null +++ b/docs/examples/coercer.md @@ -0,0 +1,145 @@ +# Coercer\ — strategy-driven, allocation-light type coercion + +**Goal:** turn “whatever came in” (JSON, strings, primitives) into the *type you actually want*—without `if/else` piles or reflection in the hot path. + +This demo shows how `Coercer` compiles a tiny **TryStrategy** pipeline once per closed generic (e.g. `Coercer`, `Coercer`) and then uses **first-match-wins** handlers to coerce values at runtime. + +--- + +## Why use it + +* **Fast path first:** already-typed values return immediately (no copies, no boxing/unboxing churn). +* **Deterministic order:** a small array of non-capturing delegates runs top-to-bottom; the first success wins. +* **Culture-safe:** the fallback conversion uses `InvariantCulture` so your tests and prod behave the same on Windows/Linux. +* **Tiny surface:** just call `Coercer.From(object?)` or `any.Coerce()`. + +--- + +## Quick start + +```csharp +using System.Text.Json; +using PatternKit.Examples.Coercion; + +// From JSON +var i = Coercer.From(JsonDocument.Parse("123").RootElement); // 123 +var b = Coercer.From(JsonDocument.Parse("true").RootElement); // true +var s = Coercer.From(JsonDocument.Parse("\"hello\"").RootElement); // "hello" +var xs = Coercer.From(JsonDocument.Parse("[\"a\",\"b\"]").RootElement); // ["a","b"] + +// From “anything” +int? viaExt = ((object)"27").Coerce(); // 27 (Convertible fallback, invariant) +double? d = ((object)"2.25").Coerce(); // 2.25 +string[]? one = ((object)"only").Coerce(); // ["only"] + +// Nulls return default(T) +int? none = Coercer.From(null); // null +``` + +--- + +## What `Coercer` handles by default + +`Coercer.From(object?)` applies these steps **in order**: + +1. **Direct cast (fast path)** + If the input is already `T`, return it as-is. + +2. **Typed handlers** (first-match-wins) + +| Input | Target `T` | Behavior | +| -------------------------- | ------------------------------------------- | -------------------------------------------- | +| `JsonElement` (any) | `string` | `je.ToString()` | +| `JsonElement` (array) | `string[]` | Enumerate elements, `.ToString()` each | +| `JsonElement` (number) | `int` / `float` / `double` (incl. nullable) | `GetInt32()` / `GetSingle()` / `GetDouble()` | +| `JsonElement` (true/false) | `bool` (incl. nullable) | `GetBoolean()` | +| `string` | `string[]` | Wrap into single-element array | + +3. **Convertible fallback (last resort)** + If the input is `IConvertible` and the target (or its nullable underlying type) is **primitive or `decimal`**, use: + +```csharp +Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture) +``` + +If no handler succeeds, return `default(T)`. + +--- + +## Ordering & priority (why the results are stable) + +The strategy array is compiled once per `Coercer` at type init. Runtime calls **don’t branch on type**; they just loop through a small array of delegates: + +* **DirectCast** runs first (zero cost success). +* A **type-specific block** (e.g., “if T is `string[]`”) injects only the relevant handlers. +* **ConvertibleFallback** is *always last* so it can’t steal cases that have precise JSON readers. + +This order is what makes things like `JsonElement 123 → int` culture-proof and fast. + +--- + +## Culture & precision notes + +* The fallback uses **`InvariantCulture`**. `"2.25"` parses as 2.25 regardless of OS locale. +* JSON numeric handlers (`GetInt32`, `GetSingle`, `GetDouble`) avoid string round-trips and honor JSON number semantics. +* If you need bankers’ rounding or custom numeric policy, add a bespoke handler (see below). + +--- + +## Extending or customizing + +`Coercer`’s default pipeline lives in `Build()`. To add a new target (e.g., `Guid`) or tweak ordering: + +* **Library edit**: add a new handler in `Build()` (e.g., for `Guid`), placing it *before* `ConvertibleFallback`. +* **Wrapper approach**: write your own converter and call it **before** `Coercer`: + +```csharp +static Guid? TryGuid(object? v) +{ + if (v is string s && Guid.TryParse(s, out var g)) return g; + if (v is JsonElement je && je.ValueKind == JsonValueKind.String && + Guid.TryParse(je.GetString(), out g)) return g; + return null; +} + +static Guid? CoerceGuid(object? v) => TryGuid(v) ?? Coercer.From(v); +``` + +Use the wrapper when you can’t or don’t want to change the shared coercer. + +--- + +## Error behavior + +No exceptions are thrown for failed coercions—handlers just return `false` and the next one tries. The fallback catches and swallows `Convert.ChangeType` exceptions. + +--- + +## Tests (TinyBDD) + +See `PatternKit.Examples.Tests/Coercion/CoercerTests.cs`. They read like specifications: + +* JSON → primitives (`int`, `float`, `double`, `bool`) +* JSON → `string` / `string[]` +* Single `string` → `string[]` +* Convertible fallback for `"27"`, `"2.25"`, etc. +* Ordering guard: JSON numeric handlers beat the fallback +* Culture stability (`InvariantCulture`) + +> Tip (Linux/macOS): our test host pins `en-US` to keep currency/number formatting stable across platforms. + +--- + +## Performance cheatsheet + +* **No LINQ** in the hot path; the handlers are a flat array of non-capturing delegates. +* **Zero alloc** for direct-cast successes; obvious allocations only when building `string[]` or when JSON `.ToString()` is used. +* **Thread-safe**: the strategy array is immutable per closed generic and reused across calls. + +--- + +## Troubleshooting + +* **Got `default(T)` back?** No handler matched. Check target type and the exact input shape (`JsonElement.ValueKind`, nullable vs. non-nullable). +* **Parsed a localized number incorrectly?** Ensure you rely on JSON handlers (preferred) or feed `InvariantCulture`-formatted text; the fallback already uses `InvariantCulture`. +* **Need decimals?** Add a `JsonElement → decimal` handler, then slot it before `ConvertibleFallback`. diff --git a/docs/examples/composed-notification-strategy.md b/docs/examples/composed-notification-strategy.md new file mode 100644 index 0000000..d48acc3 --- /dev/null +++ b/docs/examples/composed-notification-strategy.md @@ -0,0 +1,249 @@ +# Composed, Preference-Aware Notification Strategy (Email/SMS/Push/IM) + +> **TL;DR** +> We compose a notification strategy that: +> +> 1. prepends **SMS** for critical messages, +> 2. otherwise uses the user’s **preferred channel order** (de-duped, first occurrence wins), +> 3. evaluates **per-channel gates** (identity/presence/rate), +> 4. executes the **first viable** channel, and +> 5. **falls back to Email** (by design) if nothing else is viable. + +This example demonstrates how to build a production-friendly dispatcher with `` and a small set of explicit, testable components. It also shows how we validate behavior using **TinyBDD** scenarios with xUnit. + +--- + +## Why a composed strategy (and not a giant `switch`)? + +Branching logic for multi-channel notifications tends to sprawl: + +* “If critical, always try SMS first…” +* “…but only if opted in + not rate-limited.” +* “Otherwise, follow user preferences…” +* “…making sure IM is online and Push isn’t DND.” +* “…and if none apply, still send *something*.” + +A composed strategy lets us encode these as **named predicates and handlers**, then **assemble** them declaratively. The result is readable, testable, and easy to extend. + +--- + +## Model types + +* **Channels**: `` — `Email`, `Sms`, `Push`, `Im`. +* **Input**: `` — `(UserId, Message, IsCritical, Locale?)`. +* **Output**: `` — `(Channel, Success, Info?)`. + +Dependencies (`IIdentityService`, `IPresenceService`, `IRateLimiter`, `IPreferenceService`, and four sender interfaces) are small and focused. They can be synchronous or asynchronous (we use `ValueTask`/`ValueTask` everywhere). + +--- + +## The core building block: `AsyncStrategy` + +We use `` to build branchy flows that look like: + +```csharp +var strategy = AsyncStrategy.Create() + .When(IsCritical).Then(ExecuteCritical) + .Default(ExecuteByPrefs) + .Build(); +``` + +* **When/Then** pairs add branches in order. +* **Default** is the fallback handler if no predicate matches. +* Each predicate/handler has async and sync adapters, so you can pass method groups without lambda allocations. + +--- + +## Channel policies = Gate + Send + +We keep channels self-contained with ``: + +* `Gate : AsyncStrategy` — **all** checks must pass. +* `Send : Handler` — the sender to invoke if the gate allows it. + +`` wires this up: + +* **Push** gate: `HasPushToken && !DoNotDisturb && RateOkPush` +* **IM** gate: `OnlineIm && RateOkIm` +* **Email** gate: `HasVerifiedEmail && RateOkEmail` +* **SMS** gate: `HasSmsOptIn && RateOkSms` + +> Gates short-circuit on first failure (sequential checks). They’re built once and reused. + +**Why sequential?** It preserves intent and makes short-circuit behavior observable in tests (e.g., “no token → don’t even check DND or rate”). + +--- + +## Preference-aware composition + +`` builds the top-level strategy: + +1. **Critical path** + If `` is true, we **prepend `Sms`** to the order (if it isn’t already first) and evaluate gates in that order. + +2. **Preferred path** + Otherwise we ask `` for the user’s order, **de-dupe while preserving first occurrence**, then build a per-request strategy that tries each policy’s gate in that order. + +3. **Fallback** + If nothing matches, we call **Email’s send handler** as a final attempt **even if Email’s gate would fail**. That is by design to guarantee a last-ditch delivery. + +A trimmed version of the ordered execution: + +```csharp +var distinct = order.Distinct().ToList(); // preserve first occurrence + +var builder = AsyncStrategy.Create(); + +builder = distinct + .Select(ch => policies[ch]) + .Aggregate(builder, (b, p) => b.When(p.Gate.ExecuteAsync).Then(p.Send)); + +var strat = builder.Default(policies[Channel.Email].Send).Build(); +return await strat.ExecuteAsync(ctx, ct); +``` + +--- + +## Gates cheat-sheet + +| Channel | Checks (all must pass) | +| --------- | --------------------------------------------- | +| **Push** | `HasPushToken`, `!DoNotDisturb`, `RateOkPush` | +| **IM** | `OnlineIm`, `RateOkIm` | +| **Email** | `HasVerifiedEmail`, `RateOkEmail` | +| **SMS** | `HasSmsOptIn`, `RateOkSms` | + +> We invert DND using a tiny zero-alloc combinator: +> +> ```csharp +> internal static ValueTask Continue(this ValueTask t, Func f) => +> t.IsCompletedSuccessfully ? new(f(t.Result)) : Awaited(t, f); +> ``` + +--- + +## Extending with a new channel + +1. Add to `enum Channel`. +2. Implement sender interface. +3. Add identity/presence/rate checks (if any). +4. Add a gate via `ChannelPolicyFactory.Gate([...])`. +5. Add `[newChannel] = new(gate, SendNewChannel)` to `CreateAll()`. +6. Update tests (see below). + +--- + +## Testing with TinyBDD + +We use **TinyBDD + xUnit** to express readable, executable scenarios. Each test builds a small **harness** of fakes/spies and asserts outcomes. + +### Main scenarios + +1. **Preference order: first viable → Push** + +```csharp +await Given("Push is first & all push guards pass", () => + CreateHarness(h => { + h.Prefs.Set([Channel.Push, Channel.Im, Channel.Email]); + h.Id.PushToken = true; + h.Presence.DoNotDisturb = false; + h.Rate.Set(Channel.Push, true); + })) + .When("executing the strategy", Run) + .Then("result channel should be Push", x => x.R.Channel == Channel.Push) + .And("push called exactly once", x => x.H.Push.Calls == 1) + .And("no other senders called", x => x.H.Im.Calls == 0 && x.H.Email.Calls == 0 && x.H.Sms.Calls == 0) + .AssertPassed(); +``` + +2. **Skip non-viable Push → IM**, **Critical → SMS first**, **Empty prefs → Email**, + **De-dupe preserves first occurrence** and **evaluates each gate once**, + **Email gate respected when first in order → next viable (SMS)**, + **IM requires Online + Rate**, **Rate limiter is per-channel**, + **Default Email fallback ignores Email gate**. + +### Extended scenarios (behavioral edges) + +1. **Push short-circuits when no token** + We prove that **DND and rate aren’t touched** when the first check fails: + +```csharp +Then("push token checked once", x => x.t.id.HasPushTokenCalls == 1) +And("DND not checked", x => x.t.presence.DndCalls == 0) +And("push rate not checked", x => x.t.rate.PushCalls == 0) +``` + +2. **IM sender failure does not fall through** + If IM is chosen and the sender returns `Success=false`, we **do not** try Email afterward. The result is IM/false. + +3. **Fallback Email throws → cancellation propagates** + If preferences force fallback to Email and the Email sender throws (e.g., a cancelled token), we surface the exception (`TaskCanceledException` in the sample). + +4. **Tie-breakers follow declared order** + When **all gates pass**, selection follows the declared preference order (e.g., `[Sms, Push, Email, Im]` picks `Sms`). + +--- + +## Test harness fakes & spies + +We use minimal test doubles: + +* **Fakes** (`Fake*`) to collect call counts and capture `SendContext`. +* **Spies** (`Spy*`) to verify **short-circuiting** (e.g., how many times `HasPushTokenAsync` was called). +* **Throwing/Failing senders** to exercise cancellation and “no fall-through” behavior. + +This keeps assertions crisp and maps 1:1 to the production code’s intent. + +--- + +## Performance and reliability notes + +* **`ValueTask` everywhere**: avoid allocations on sync-fast paths. +* **Method groups over lambdas**: fewer allocations, clearer intent. +* **No per-call closures** inside the strategy builder: policies are created once and reused. +* **Short-circuit gates**: only the minimum number of checks is executed. +* **Thread-safe composition**: the built strategy is immutable; ensure your dependencies are thread-safe. + +--- + +## Troubleshooting + +* **“Argument 2: cannot convert from ‘method group’…”** + Ensure the method group **matches the delegate** exactly. For example, `When(policy.Gate.ExecuteAsync)` expects `Func>`. If your method has default parameters or a different signature, **wrap** it: + + ```csharp + b.When((ctx, ct) => policy.Gate.ExecuteAsync(ctx, ct)); + ``` + +* **Email fallback ignores Email gate** + That’s **intentional**: we guarantee a last-ditch attempt to send something. + +--- + +## Quick start + +```csharp +// Wire your real services here +var strategy = ComposedStrategies.BuildPreferenceAware( + id, presence, rate, prefs, email, sms, push, im); + +var result = await strategy.ExecuteAsync( + new SendContext(userId, "Hello from PatternKit!", isCritical: false), + CancellationToken.None); + +// result.Channel: which channel was executed +// result.Success: whether the sender reported success +``` + +--- + +## Cross-references + +* `` +* `` +* `` +* `` +* `` +* `` +* `` + diff --git a/docs/examples/config-driven-transaction-pipeline.md b/docs/examples/config-driven-transaction-pipeline.md new file mode 100644 index 0000000..c231a83 --- /dev/null +++ b/docs/examples/config-driven-transaction-pipeline.md @@ -0,0 +1,267 @@ +# Config-driven transaction pipeline (DI + fluent chains) + +> **Goal** +> Build a checkout pipeline where **what runs** and **in what order** comes from configuration, while the execution remains allocation-lean and testable. + +This demo layers a small configuration model over the same primitives used in the mediated pipeline: + +* **Action chains** for branchless *discounts → tax* and *rounding* +* **Tender handling** via a first-match router (see the mediated pipeline doc) +* **DI registration** that composes a single immutable [xref\:PatternKit.Examples.Chain.TransactionPipeline](xref:PatternKit.Examples.Chain.TransactionPipeline) at startup + +--- + +## Quick start + +1. **Add configuration** (order matters): + +```json +// appsettings.json +{ + "Payment": { + "Pipeline": { + "DiscountRules": [ "discount:cash-2pc", "discount:loyalty-5pc", "discount:bundle-1off" ], + "Rounding": [ "round:charity", "round:nickel-cash-only" ], + "TenderOrder": [ "tender:cash", "tender:card" ] // informational + } + } +} +``` + +2. **Register the pipeline** in DI: + +```csharp +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.Chain.ConfigDriven; + +var services = new ServiceCollection(); +services.AddPaymentPipeline(configuration); // builds a TransactionPipeline from config +var provider = services.BuildServiceProvider(); + +var pipe = provider.GetRequiredService(); +var (result, ctx) = pipe.Run(new TransactionContext { + Customer = new Customer(LoyaltyId: "LOYAL-123", AgeYears: 25), + Items = [ new LineItem("SKU-1", 22.97m) ], + Tenders = [ new Tender(PaymentKind.Cash, CashGiven: 20m), + new Tender(PaymentKind.Card, CardAuthType.Contactless, CardVendor.Visa) ] +}); +``` + +3. **Done** — the runtime pipeline is immutable and safe to reuse concurrently. + +--- + +## What’s in the box + +### Configuration model + +[xref\:PatternKit.Examples.Chain.ConfigDriven.PipelineOptions](xref:PatternKit.Examples.Chain.ConfigDriven.PipelineOptions) drives ordering: + +* `DiscountRules`: keys of discount rules to apply **in order** +* `Rounding`: keys of rounding strategies to apply **in order** +* `TenderOrder`: optional, informational (e.g., control UI ordering) + +Unknown keys are **ignored** (we map by key and skip missing entries). + +### Strategies provided + +**Discount rules** (keys): + +* `discount:cash-2pc` → [xref\:PatternKit.Examples.Chain.ConfigDriven.Cash2Pct](xref:PatternKit.Examples.Chain.ConfigDriven.Cash2Pct) + First tender is cash → 2% off `Subtotal` +* `discount:loyalty-5pc` → [xref\:PatternKit.Examples.Chain.ConfigDriven.Loyalty5Pct](xref:PatternKit.Examples.Chain.ConfigDriven.Loyalty5Pct) + Loyalty present → 5% off `Subtotal` +* `discount:bundle-1off` → [xref\:PatternKit.Examples.Chain.ConfigDriven.Bundle1OffEach](xref:PatternKit.Examples.Chain.ConfigDriven.Bundle1OffEach) + Any `BundleKey` with total `Qty ≥ 2` → \$1 off per item in those bundles + +**Rounding** (keys): + +* `round:charity` → [xref\:PatternKit.Examples.Chain.ConfigDriven.CharityRoundUp](xref:PatternKit.Examples.Chain.ConfigDriven.CharityRoundUp) + If any `CHARITY:*` SKU is present → round up to next dollar +* `round:nickel-cash-only` → [xref\:PatternKit.Examples.Chain.ConfigDriven.NickelCashOnly](xref:PatternKit.Examples.Chain.ConfigDriven.NickelCashOnly) + Cash-only transactions → round to nearest \$0.05 (logs “skipped (not cash-only)” otherwise) + +**Tender handlers** (DI-registered): + +* [xref\:PatternKit.Examples.Chain.ConfigDriven.CashTender](xref:PatternKit.Examples.Chain.ConfigDriven.CashTender) (`tender:cash`) +* [xref\:PatternKit.Examples.Chain.ConfigDriven.CardTender](xref:PatternKit.Examples.Chain.ConfigDriven.CardTender) (`tender:card`) + +> The router itself is assembled by the mediated pipeline pieces; we simply **supply handlers via DI** and the builder wires them into the tender stage. + +--- + +## How it composes + +### Discounts & tax (config-driven) + +[xref\:PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineBuilderExtensions.AddConfigDrivenDiscountsAndTax\*](xref:PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineBuilderExtensions.AddConfigDrivenDiscountsAndTax*): + +* Recomputes `Subtotal` +* Iterates `opts.Value.DiscountRules` and applies each rule that exists in the DI map +* Computes **tax** at **8.75%** of `(Subtotal − DiscountTotal)` and logs `pre-round total` + +```csharp +b.AddConfigDrivenDiscountsAndTax(opts, discountRules); +``` + +### Rounding (config-driven) + +[xref\:PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineBuilderExtensions.AddConfigDrivenRounding\*](xref:PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineBuilderExtensions.AddConfigDrivenRounding*): + +* Iterates `opts.Value.Rounding` and calls each strategy in order +* Each strategy decides to apply or log “skipped” +* Logs final `total` + +```csharp +b.AddConfigDrivenRounding(opts, rounding); +``` + +### DI registration + +[xref\:PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineDemo.AddPaymentPipeline\*](xref:PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineDemo.AddPaymentPipeline*): + +* Binds `Payment:Pipeline` to [xref\:PatternKit.Examples.Chain.ConfigDriven.PipelineOptions](xref:PatternKit.Examples.Chain.ConfigDriven.PipelineOptions) +* Registers: + + * Infra: [xref\:PatternKit.Examples.Chain.IDeviceBus](xref:PatternKit.Examples.Chain.IDeviceBus), [xref\:PatternKit.Examples.Chain.CardProcessors](xref:PatternKit.Examples.Chain.CardProcessors) + * Discounts: `Cash2Pct`, `Loyalty5Pct`, `Bundle1OffEach` + * Rounding: `CharityRoundUp`, `NickelCashOnly` + * Tenders: `CashTender`, `CardTender` +* Builds a shared [xref\:PatternKit.Examples.Chain.TransactionPipeline](xref:PatternKit.Examples.Chain.TransactionPipeline): + +```csharp +TransactionPipelineBuilder.New() + .WithDeviceBus(devices) + .AddPreauth() + .AddConfigDrivenDiscountsAndTax(opts, discountRules) + .AddConfigDrivenRounding(opts, rounding) + .WithTenderHandlers(tenderHandlers) + .AddTenderHandling() + .AddFinalize() + .Build(); +``` + +Consumers receive a thin wrapper: [xref\:PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineDemo.PaymentPipeline](xref:PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineDemo.PaymentPipeline) with `Run(ctx)`. + +--- + +## Example scenarios (from tests) + +### Mixed tender (cash then card) — **no** nickel rounding + +* Config: `Rounding = ["round:nickel-cash-only"]` +* Items: `Subtotal 22.97 → Tax 2.01 → Pre-round 24.98` +* Tenders: `$20 cash`, then `Visa` pays the remainder + +**Outcome** + +* Rounding skipped (not cash-only) +* Card captures `$4.98` +* Result: `paid` + +See: `TransactionPipelineDemoTests.MixedTender_NoNickelRounding`. + +### Cash-only nickel rounding **up** to `$25.00` + +* Config: `Rounding = ["round:nickel-cash-only"]` +* Pre-round total `24.98` +* Rounding adds `+$0.02` +* Single cash tender `$25.00` → paid + +See: `TransactionPipelineDemoTests.CashOnly_NickelRounding_Up`. + +### Charity round-up + +* Config: `Rounding = ["round:charity"]` +* Presence of `CHARITY:RedCross` SKU causes `+$0.02` to next whole dollar +* Paid by card + +See: `TransactionPipelineDemoTests.Charity_RoundUp_Works`. + +### Preauth block (age) + +* Age-restricted item + underage customer → `TxResult.Fail("age", ...)` +* Pipeline stops early + +See: `TransactionPipelineDemoTests.Preauth_AgeBlock`. + +--- + +## Extending with your own rules/strategies/handlers + +1. **Implement** the interface and choose a unique key: + +```csharp +public sealed class Employee10Pct : IDiscountRule +{ + public string Key => "discount:employee-10pc"; + public void Apply(TransactionContext ctx) + { + if (ctx.Customer.LoyaltyId == "EMP") + ctx.AddDiscount(Math.Round(ctx.Subtotal * 0.10m, 2), "employee 10%"); + } +} +``` + +2. **Register** it: + +```csharp +services.AddSingleton(); +``` + +3. **Enable** it in config (order is important): + +```json +"Payment": { "Pipeline": { "DiscountRules": [ + "discount:employee-10pc", "discount:bundle-1off" +]}} +``` + +The same pattern holds for `IRoundingStrategy` and `ITenderHandler`. + +--- + +## FAQ & tips + +* **What happens if a key is listed but not registered?** + It’s skipped; we only apply rules found in the DI map. + +* **Where’s the tax rate?** + Inside `AddConfigDrivenDiscountsAndTax` we compute tax at **8.75%**. Swap this with your own calculator if needed. + +* **Thread safety?** + The composed [xref\:PatternKit.Examples.Chain.TransactionPipeline](xref:PatternKit.Examples.Chain.TransactionPipeline) is immutable and safe for concurrent use. Builders are not thread-safe. + +* **Observability** + Every rule/strategy logs its work to `ctx.Log` using concise, human-readable entries suitable for unit tests and diagnostics. + +* **Performance** + + * All composition happens **once** at startup. + * Execution uses arrays and `static` delegates where possible to minimize allocations. + * The config-driven action chains still short-circuit *inside* each component when appropriate. + +--- + +## Reference + +* Composition + + * [xref\:PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineBuilderExtensions](xref:PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineBuilderExtensions) + * [xref\:PatternKit.Examples.Chain.TransactionPipelineBuilder](xref:PatternKit.Examples.Chain.TransactionPipelineBuilder) + * [xref\:PatternKit.Examples.Chain.TransactionPipeline](xref:PatternKit.Examples.Chain.TransactionPipeline) +* Config & DI + + * [xref\:PatternKit.Examples.Chain.ConfigDriven.PipelineOptions](xref:PatternKit.Examples.Chain.ConfigDriven.PipelineOptions) + * [xref\:PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineDemo.AddPaymentPipeline\*](xref:PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineDemo.AddPaymentPipeline*) + * [xref\:PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineDemo.PaymentPipeline](xref:PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineDemo.PaymentPipeline) +* Strategies + + * Discounts: [xref\:PatternKit.Examples.Chain.ConfigDriven.Cash2Pct](xref:PatternKit.Examples.Chain.ConfigDriven.Cash2Pct), [xref\:PatternKit.Examples.Chain.ConfigDriven.Loyalty5Pct](xref:PatternKit.Examples.Chain.ConfigDriven.Loyalty5Pct), [xref\:PatternKit.Examples.Chain.ConfigDriven.Bundle1OffEach](xref:PatternKit.Examples.Chain.ConfigDriven.Bundle1OffEach) + * Rounding: [xref\:PatternKit.Examples.Chain.ConfigDriven.CharityRoundUp](xref:PatternKit.Examples.Chain.ConfigDriven.CharityRoundUp), [xref\:PatternKit.Examples.Chain.ConfigDriven.NickelCashOnly](xref:PatternKit.Examples.Chain.ConfigDriven.NickelCashOnly) + * Tenders: [xref\:PatternKit.Examples.Chain.ConfigDriven.CashTender](xref:PatternKit.Examples.Chain.ConfigDriven.CashTender), [xref\:PatternKit.Examples.Chain.ConfigDriven.CardTender](xref:PatternKit.Examples.Chain.ConfigDriven.CardTender) +* Domain + + * [xref\:PatternKit.Examples.Chain.TransactionContext](xref:PatternKit.Examples.Chain.TransactionContext), [xref\:PatternKit.Examples.Chain.TxResult](xref:PatternKit.Examples.Chain.TxResult), + [xref\:PatternKit.Examples.Chain.Tender](xref:PatternKit.Examples.Chain.Tender), [xref\:PatternKit.Examples.Chain.LineItem](xref:PatternKit.Examples.Chain.LineItem), [xref\:PatternKit.Examples.Chain.Customer](xref:PatternKit.Examples.Chain.Customer) diff --git a/docs/examples/index.md b/docs/examples/index.md new file mode 100644 index 0000000..f7bed25 --- /dev/null +++ b/docs/examples/index.md @@ -0,0 +1,78 @@ +# Examples & Demos + +Welcome! This section collects small, focused demos that show **how to compose behaviors with PatternKit**—without sprawling frameworks, if/else ladders, or tangled control flow. Each demo is production-shaped, tiny, and easy to lift into your own code. + +## What you’ll see + +* **First-match-wins strategies** for branching without `if` chains. +* **Branchless action chains** for rule packs (logging, pre-auth, discounts, tax). +* **Pipelines** built declaratively and tested end-to-end. +* **Config-driven composition** (DI + `IOptions`) so ops can re-order rules without redeploys. +* **Strategy-based coercion** for turning “whatever came in” into the types you actually want. +* **Ultra-minimal HTTP routing** to illustrate middleware vs. routes vs. negotiation. + +## Demos in this section + +* **Composed, Preference-Aware Notification Strategy (Email/SMS/Push/IM)** + Shows how to layer a user’s channel preferences, failover, and throttling into a composable **Strategy** without `switch`es. Good template for “try X, else Y” flows (alerts, KYC, etc.). + +* **Auth & Logging Chain** + A tiny `ActionChain` showing **request ID logging**, an **auth short-circuit** for `/admin/*`, and the subtleties of **`.ThenContinue` vs `.ThenStop` vs `Finally`** (strict-stop semantics by default). + +* **Strategy-Based Data Coercion** + `Coercer` compiles a tiny set of **TryStrategy** handlers once per target type to coerce JSON/primitives/strings at runtime—**first-match-wins**, culture-safe, allocation-light. + +* **Mediated Transaction Pipeline** + An in-code pipeline (no config) that demonstrates **ActionChain**, **Strategy**, and **TryStrategy** together: pre-auth checks, discounting, tax, rounding, tender handling, and finalization. Emphasizes clear logs and testable rules. + +* **Configuration-Driven Transaction Pipeline** + Same business shape as above, but wired via DI + `IOptions`. Discounts/rounding/tenders are discovered and **ordered from config**, making the pipeline operationally tunable. + +* **Minimal Web Request Router** + A tiny “API gateway” that separates **first-match middleware** (side effects/logging/auth) from **first-match routes** and **content negotiation**. A crisp example of Strategy patterns in an HTTP-ish setting. + +## How to run + +From the repo root: + +```bash +# Build everything +dotnet build PatternKit.slnx -c Release + +# Run all tests (quick, cross-targeted) +dotnet test PatternKit.slnx -c Release +``` + +> Tip (Linux/macOS): our tests force `en-US` culture to make currency/text output stable across platforms. +> If you run outside the test host, ensure invariant globalization isn’t enabled: +> +> * `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT` should be **unset** or **0**. +> * To see console output, execute demo entrypoints (e.g., `Demo.Run`) from your IDE or a small console host. + +## Design highlights + +* **First-match wins** everywhere (middleware, routes, rounding rules, tender routers). +* **Zero-`if` routing** via `BranchBuilder` and delegates (`Predicate` → `Handler`). +* **Branchless rule packs** via `ActionChain` with `When/ThenContinue/ThenStop/Finally`. +* **Immutable, thread-safe artifacts** after `.Build()`; builders remain mutable. +* **Tiny domain types** you can replace or extend (requests, responses, tenders, items, rules). + +## Where to look (quick map) + +* **Auth & Logging Chain:** `AuthLoggingDemo` (+ `AuthLoggingDemoTests`) — strict-stop auth with logging. +* **Coercer:** `Coercer` (+ `CoercerTests`) — strategy-based, culture-safe coercion. +* **Mini Router:** `MiniRouter` + `Demo.Run` — middleware/auth/negotiation in console output. +* **Mediated Pipeline:** `TransactionPipelineBuilder` + `MediatedTransactionPipelineDemo.Run`. +* **Config-Driven Pipeline:** `ConfigDrivenPipelineDemo.AddPaymentPipeline` + `PipelineOptions`. +* **Tests:** `PatternKit.Examples.Tests/*` use TinyBDD scenarios that read like specs. + +## Why these demos exist + +They’re meant to be **copy-pasteable patterns**: + +* Replace cascade `if/else` with **composed strategies**. +* Turn scattered rules into a **linear, testable chain**. +* Move “what runs & in what order” to **configuration**, when appropriate. +* Keep the primitives small so the system stays legible under change. + +Jump in via the pages in the left-hand ToC and open the corresponding test files—the assertions double as executable documentation. \ No newline at end of file diff --git a/docs/examples/mediated-transaction-pipeline.md b/docs/examples/mediated-transaction-pipeline.md new file mode 100644 index 0000000..da6f43c --- /dev/null +++ b/docs/examples/mediated-transaction-pipeline.md @@ -0,0 +1,280 @@ +# Mediated transaction pipeline (demo) + +> **TL;DR** +> This sample shows how to build a production-grade checkout pipeline by **composing small, testable stages**. +> We combine: +> +> * **Action chains** for branchless pre-auth, discounts, and tax +> * A **BranchBuilder-powered router** for tender handling +> * A tiny **rounding rule engine** (first-match-wins) +> * A lightweight **pipeline runner** with **short-circuit** semantics + +Everything runs on in-memory types so you can exercise it entirely from unit tests. + +--- + +## What we’re building + +The pipeline transforms a mutable [xref\:PatternKit.Examples.Chain.TransactionContext](xref:PatternKit.Examples.Chain.TransactionContext) through a series of stages and returns a terminal [xref\:PatternKit.Examples.Chain.TxResult](xref:PatternKit.Examples.Chain.TxResult): + +``` +[Preauth] → [Discounts & Tax] → [Rounding] → [Tender Handling] → [Finalize] + | | | | + v v v v + updates ctx updates ctx updates ctx sets terminal + (no return) (no return) (no return) TxResult + beeps +``` + +* Each stage has signature `bool Stage(TransactionContext ctx)` — **true** = continue, **false** = stop. +* Stages mutate `ctx` (totals, logs, tender progress) and may set `ctx.Result` to a terminal value. + +--- + +## Core concepts + +### The `Stage` delegate and the pipeline runner + +* `Stage` is just: **mutate ctx, decide continue/stop**. +* [xref\:PatternKit.Examples.Chain.TransactionPipeline](xref:PatternKit.Examples.Chain.TransactionPipeline) runs the ordered stages and enforces a terminal result: + + * If any stage returns **false**: stop and return the current `ctx.Result`. + * If no stage stopped and `ctx.Result` is still null: force success `("paid", "paid in full")`. + +```csharp +var (result, finalCtx) = new TransactionPipeline(stages).Run(ctx); +``` + +### Adapting action chains to stages + +We use [xref\:PatternKit.Behavioral.Chain.ActionChain%601](xref:PatternKit.Behavioral.Chain.ActionChain%601) for **branchless** logic and adapt it to a `Stage` via `ChainStage.From(chain)`. +If an action chain sets a failing `ctx.Result`, the adapted stage returns **false** to short-circuit the pipeline. + +--- + +## Building the pipeline + +Use [xref\:PatternKit.Examples.Chain.TransactionPipelineBuilder](xref:PatternKit.Examples.Chain.TransactionPipelineBuilder) to declaratively assemble stages: + +```csharp +var pipeline = TransactionPipelineBuilder.New() + .AddPreauth() // age restriction, empty basket + .AddDiscountsAndTax() // cash 2%, loyalty 5%, coupons, bundle, then tax + .AddRounding() // first matching rounding rule + .AddTenderHandling() // cash + card handlers via BranchBuilder router + .AddFinalize() // terminal result + device beep + .Build(); + +var (result, ctx) = pipeline.Run(transactionCtx); +``` + +### 1) Pre-authorization (no `if/else`) + +Implemented as an **ActionChain** that stops on the first failing rule: + +* Age restricted items require `Customer.AgeYears ≥ 21` +* Basket must not be empty + +On failure: sets `ctx.Result = TxResult.Fail(...)`, logs the reason, **stops** the pipeline. + +### 2) Discounts & tax (no `if/else`) + +Also an **ActionChain**: + +* Cash-first: **2%** off +* Loyalty present: **5%** off +* Manufacturer coupons (sum per item × qty) +* In-house coupons +* Bundle deal: items sharing a `BundleKey` with total `Qty ≥ 2` → `$1 off` per unit in the bundle +* Then compute tax at **8.75%** of `(Subtotal - DiscountTotal)` + +All values are rounded to two decimals at the point of application. + +### 3) Rounding rules (first-match-wins) + +[xref\:PatternKit.Examples.Chain.RoundingPipeline](xref:PatternKit.Examples.Chain.RoundingPipeline) evaluates `IRoundingRule` implementations **in order** and applies the first that matches: + +* [xref\:PatternKit.Examples.Chain.CharityRoundUpRule](xref:PatternKit.Examples.Chain.CharityRoundUpRule) — if any `Sku` starts with `CHARITY:`, round up to next dollar and notify [xref\:PatternKit.Examples.Chain.ICharityTracker](xref:PatternKit.Examples.Chain.ICharityTracker) +* [xref\:PatternKit.Examples.Chain.NickelCashOnlyRule](xref:PatternKit.Examples.Chain.NickelCashOnlyRule) — if *all tenders are cash* and a `ROUND:NICKEL` SKU is present, round to nearest \$0.05 + +Every rule logs the delta and the new total when applied. + +You can override rules with: + +```csharp +TransactionPipelineBuilder.New() + .WithRoundingRules(new MyRule(), new NickelCashOnlyRule()) + ... +``` + +### 4) Tender handling (BranchBuilder router) + +Handlers live behind a **zero-`if` router** built by [xref\:PatternKit.Examples.Chain.TenderRouterFactory](xref:PatternKit.Examples.Chain.TenderRouterFactory): + +* Each handler implements `ITenderHandler` (from `ConfigDriven` sample) with: + + * `CanHandle(ctx, tender) : bool` + * `Handle(ctx, tender) : TxResult` +* The router is composed with [xref\:PatternKit.Creational.Builder.BranchBuilder\`2](xref:PatternKit.Creational.Builder.BranchBuilder`2): + + * Evaluate handlers **in order** + * First predicate that returns **true** runs its handler + * If none match, return `TxResult.Fail("route", ...)` + +By default (if you don’t call `WithTenderHandlers(...)`) we register: + +* `CashTender` — opens drawer, applies cash, calculates change +* `CardTender` — resolves a processor by vendor, `Authorize` then `Capture`, applies payment + +You can supply your own: + +```csharp +builder.WithTenderHandlers(new MyGiftCardHandler(), new CashTender(devices)); +``` + +### 5) Finalization + +If no stage failed: + +* If `ctx.RemainderDue > 0`: `Fail("insufficient", "...")` +* Else: `Success("paid", "paid in full")` and `devices.Beep("printer", 2)` + +Always logs `"done."`. + +--- + +## End-to-end scenario (from tests) + + +- **Feature:** Cash + loyalty + two cigarettes + - **Given:** customer age 25, loyalty `"LOYAL-123"`, tenders: `$50` cash, items: + * `CIGS` \$10.96 × 1 (age-restricted, bundle `CIGS`) + * `CIGS` \$10.97 × 1 (age-restricted, bundle `CIGS`) + - **Then** the pipeline computes: + * Subtotal = **21.93** + * Discounts: + + * Cash 2% = **0.44** + * Loyalty 5% = **1.10** + * Bundle deal = **2.00** + * **Total discounts = 3.54** + * Tax (8.75% of 21.93 − 3.54 = 18.39) = **1.61** + * Grand total = **20.00** + * Cash given 50 → Change = **30.00** + * Terminal result = **Ok=true, Code="paid"** + * Log contains: `"preauth: ok"`, individual discount entries, `"tax:"`, `"total:"`, and `"done."` + +> This is codified in `MediatedTransactionPipelineDemoTests` using TinyBDD. + +--- + +## Extensibility points + +* **Devices:** `.WithDeviceBus(IDeviceBus)` (beeper, cash drawer, etc.) +* **Tender handlers:** `.WithTenderHandlers(params ITenderHandler[])` +* **Rounding rules:** `.WithRoundingRules(params IRoundingRule[])` +* **Arbitrary stages:** `.AddStage(Stage)` or `.AddStage(ActionChain)` + +Because stages are just delegates, you can encapsulate feature slices (fraud checks, gift cards, store credit, EBT, etc.) as separate assemblies and drop them into the builder. + +--- + +## Routing without `if/else` + +The tender router is built once and runs hot: + +```csharp +public static TenderRouter Build(IEnumerable handlers) +{ + var bb = BranchBuilder.Create(); + + foreach (var h in handlers) + bb.Add( + (in c, in t) => h.CanHandle(c, t), // predicate + (c, in t) => h.Handle(c, t)); // handler + + return bb.Build( + fallbackDefault: static (ctx, in t) + => TxResult.Fail("route", $"no handler for {t.Kind}"), + projector: static (preds, steps, _, def) => (ctx, in t) => + { + for (var i = 0; i < preds.Length; i++) + if (preds[i](in ctx, in t)) return steps[i](ctx, in t); + return def(ctx, in t); + }); +} +``` + +* **Zero allocations** per call (no lambdas captured, all signatures are exact). +* **Short-circuit** on first match. + +--- + +## Performance notes + +* Most delegates are `static` and use **`in` parameters** to avoid copies. +* Arithmetic is rounded at the **moment of application** to keep totals stable. +* The pipeline, chains, and router are **immutable** after `Build()`; safe for concurrent use. + +--- + +## Running the demo programmatically + +If you just want the sensible defaults: + +```csharp +var ctx = new TransactionContext +{ + Customer = new Customer(LoyaltyId: "LOYAL-123", AgeYears: 25), + Tender = new Tender(PaymentKind.Cash, CashGiven: 50m), + Items = + [ + new LineItem("CIGS", 10.96m, Qty: 1, AgeRestricted: true, BundleKey: "CIGS"), + new LineItem("CIGS", 10.97m, Qty: 1, AgeRestricted: true, BundleKey: "CIGS"), + ] +}; + +var (result, finalCtx) = MediatedTransactionPipelineDemo.Run(ctx); +// result.Ok == true, result.Code == "paid" +``` + +--- + +## Troubleshooting + +* **Pipeline stops early** + Check `ctx.Result` and `ctx.Log`. Any stage can set a failing result and return `false` to short-circuit. + +* **Totals don’t add up** + Ensure you call `RecomputeSubtotal()` before computing discounts and tax (the included chain does this first). + +* **Rounding not applied** + Confirm rule order and `ShouldApply` conditions. Only the **first** matching rule runs. + +* **Tender not handled** + Verify handler order and `CanHandle` predicates. The router is **first-match-wins**; add a fallback handler or rely on the built-in `"route"` failure. + +--- + +## API reference (selected) + +* Pipeline + + * [xref\:PatternKit.Examples.Chain.TransactionPipeline](xref:PatternKit.Examples.Chain.TransactionPipeline) + * [xref\:PatternKit.Examples.Chain.TransactionPipelineBuilder](xref:PatternKit.Examples.Chain.TransactionPipelineBuilder) + * [xref\:PatternKit.Examples.Chain.MediatedTransactionPipelineDemo](xref:PatternKit.Examples.Chain.MediatedTransactionPipelineDemo) +* Domain types & services + + * [xref\:PatternKit.Examples.Chain.TransactionContext](xref:PatternKit.Examples.Chain.TransactionContext), [xref\:PatternKit.Examples.Chain.TxResult](xref:PatternKit.Examples.Chain.TxResult), [xref\:PatternKit.Examples.Chain.Tender](xref:PatternKit.Examples.Chain.Tender), [xref\:PatternKit.Examples.Chain.LineItem](xref:PatternKit.Examples.Chain.LineItem), [xref\:PatternKit.Examples.Chain.Customer](xref:PatternKit.Examples.Chain.Customer) + * [xref\:PatternKit.Examples.Chain.IDeviceBus](xref:PatternKit.Examples.Chain.IDeviceBus), [xref\:PatternKit.Examples.Chain.ICardProcessor](xref:PatternKit.Examples.Chain.ICardProcessor), [xref\:PatternKit.Examples.Chain.CardProcessors](xref:PatternKit.Examples.Chain.CardProcessors) +* Rounding + + * [xref\:PatternKit.Examples.Chain.IRoundingRule](xref:PatternKit.Examples.Chain.IRoundingRule), [xref\:PatternKit.Examples.Chain.CharityRoundUpRule](xref:PatternKit.Examples.Chain.CharityRoundUpRule), [xref\:PatternKit.Examples.Chain.NickelCashOnlyRule](xref:PatternKit.Examples.Chain.NickelCashOnlyRule), [xref\:PatternKit.Examples.Chain.RoundingPipeline](xref:PatternKit.Examples.Chain.RoundingPipeline) +* Tender routing + + * [xref\:PatternKit.Examples.Chain.TenderRouterFactory](xref:PatternKit.Examples.Chain.TenderRouterFactory) + * [xref\:PatternKit.Creational.Builder.BranchBuilder\`2](xref:PatternKit.Creational.Builder.BranchBuilder`2) + * [xref\:PatternKit.Creational.Builder.ChainBuilder\`1](xref:PatternKit.Creational.Builder.ChainBuilder`1) +* Chains + + * [xref\:PatternKit.Behavioral.Chain.ActionChain%601](xref:PatternKit.Behavioral.Chain.ActionChain%601) (used via `ChainStage.From(...)`) + diff --git a/docs/examples/mini-router.md b/docs/examples/mini-router.md new file mode 100644 index 0000000..214b3a7 --- /dev/null +++ b/docs/examples/mini-router.md @@ -0,0 +1,273 @@ +# MiniRouter — a tiny, composable API gateway/router + +> **TL;DR** +> `MiniRouter` shows how three PatternKit primitives compose into a pragmatic HTTP-ish pipeline: +> +> * **Middleware** (side-effects, first-match-wins) — built with [xref\:PatternKit.Behavioral.Strategy.ActionStrategy\`1](xref:PatternKit.Behavioral.Strategy.ActionStrategy`1) +> * **Routes** (return a response, first-match-wins) — built with [xref\:PatternKit.Behavioral.Strategy.Strategy\`2](xref:PatternKit.Behavioral.Strategy.Strategy`2) +> * **Content negotiation** (try handlers until one succeeds) — built with [xref\:PatternKit.Behavioral.Strategy.TryStrategy\`2](xref:PatternKit.Behavioral.Strategy.TryStrategy`2) + +This sample lives in `PatternKit.Examples.ApiGateway` and is intentionally tiny so you can lift it into tests, demos, or “just-enough” services. + +--- + +## What it is (and isn’t) + +**MiniRouter** is not a web framework. It’s a **pure-function** pipeline you can exercise from unit tests or swap behind your existing transport (ASP.NET, Minimal APIs, Lambda, etc.). It demonstrates: + +* A **first-match-wins** mindset across middleware and routing +* **Short-circuit** behavior (e.g., auth checks) without exceptions +* **Allocation-light** execution with by-`in` parameters and static lambdas +* Clean separation of **effects** (middleware) and **results** (routes) + +--- + +## The primitives + +### Request/Response + +* [xref\:PatternKit.Examples.ApiGateway.Request](xref:PatternKit.Examples.ApiGateway.Request) is a tiny immutable input: `Method`, `Path`, `Headers`, `Body?` +* [xref\:PatternKit.Examples.ApiGateway.Response](xref:PatternKit.Examples.ApiGateway.Response) is an immutable output: `StatusCode`, `ContentType`, `Body` +* [xref\:PatternKit.Examples.ApiGateway.Responses](xref:PatternKit.Examples.ApiGateway.Responses) holds helpers: `Text`, `Json`, `NotFound`, `Unauthorized` + +### Router + +* [xref\:PatternKit.Examples.ApiGateway.MiniRouter](xref:PatternKit.Examples.ApiGateway.MiniRouter) composes three strategies: + + * `_middleware : ActionStrategy` — **fire-and-forget** side effects (logging, metrics, auth messages) + * `_routes : Strategy` — **produce** a `Response` + * `_negotiate : TryStrategy` — **select** a `Content-Type` if a route left it blank + +--- + +## Building a router + +Use the fluent builder: + +```csharp +var router = MiniRouter.Create() + // --- middleware (first-match-wins) --- + .Use( + static (in r) => r.Headers.ContainsKey("X-Request-Id"), + static (in r) => Console.WriteLine($"reqid={r.Headers["X-Request-Id"]}")) + .Use( + static (in r) => r.Path.StartsWith("/admin", StringComparison.Ordinal) && + !r.Headers.ContainsKey("Authorization"), + static (in _) => Console.WriteLine("Denied: missing Authorization")) + + // --- routes (first-match-wins) --- + .Map( + static (in r) => r is { Method: "GET", Path: "/health" }, + static (in _) => Responses.Text(200, "OK")) + .Map( + static (in r) => r.Method == "GET" && r.Path.StartsWith("/users/", StringComparison.Ordinal), + static (in r) => + { + var idStr = r.Path["/users/".Length..]; + return int.TryParse(idStr, out var id) + ? Responses.Json(200, $"{{\"id\":{id},\"name\":\"user{id}\"}}") + : Responses.Text(404, "User not found"); + }) + .Map( + static (in r) => r is { Method: "POST", Path: "/users" }, + static (in _) => Responses.Json(201, "{\"ok\":true}")) + + // auth route fallback (denied) + .Map( + static (in r) => r.Path.StartsWith("/admin", StringComparison.Ordinal) && + !r.Headers.ContainsKey("Authorization"), + static (in _) => Responses.Unauthorized()) + + // default + .NotFound(static (in _) => Responses.NotFound()) + .Build(); +``` + +**Notes** + +* **Middleware** uses [xref\:PatternKit.Behavioral.Strategy.ActionStrategy\`1](xref:PatternKit.Behavioral.Strategy.ActionStrategy`1): *first* matching action runs; others are skipped. +* **Routes** use [xref\:PatternKit.Behavioral.Strategy.Strategy\`2](xref:PatternKit.Behavioral.Strategy.Strategy`2): *first* matching handler returns a `Response`. +* A **default route** is set via `.NotFound(...)`. +* The builder sets a **noop default middleware** so `.Handle` never throws due to “no middleware.” + +--- + +## Content negotiation + +If a route returns an empty `ContentType`, **MiniRouter** will ask the negotiator to supply one. The **default negotiator**: + +1. If `Accept: application/json` → `application/json; charset=utf-8` +2. Else if `Accept: text/plain` → `text/plain; charset=utf-8` +3. Else → default to JSON + +You can provide your own: + +```csharp +var neg = TryStrategy.Create() + .Always(static (in r, out string? ct) => + { + if (r.Headers.TryGetValue("Accept", out var a) && a.Contains("application/xml")) + { ct = "application/xml; charset=utf-8"; return true; } + ct = null; return false; + }) + .Finally(static (in _, out string? ct) => { ct = "application/json; charset=utf-8"; return true; }) + .Build(); + +var router = MiniRouter.Create() + // ... Use/Map/NotFound ... + .WithNegotiator(neg) + .Build(); +``` + +--- + +## Demo walkthrough + +`Demo.Run()` wires the router, then simulates requests: + +```csharp +Print(router.Handle(new Request("GET", "/health", commonHeaders))); +Print(router.Handle(new Request("GET", "/users/42", commonHeaders))); +Print(router.Handle(new Request("GET", "/users/abc", commonHeaders))); +Print(router.Handle(new Request("GET", "/admin/metrics", new Dictionary()))); // unauthorized +Print(router.Handle(new Request("POST", "/users", commonHeaders, "{\"name\":\"Ada\"}"))); +Print(router.Handle(new Request("GET", "/nope", commonHeaders))); +``` + +**Illustrative output** (order matters—note the middleware log before the 401): + +``` +200 text/plain; charset=utf-8 +OK + +200 application/json; charset=utf-8 +{"id":42,"name":"user42"} + +404 text/plain; charset=utf-8 +User not found + +Denied: missing Authorization +401 text/plain; charset=utf-8 +Unauthorized + +201 application/json; charset=utf-8 +{"ok":true} + +404 text/plain; charset=utf-8 +Not Found +``` + +--- + +## Why three strategies? + +| Concern | Type | Behavior | Why here | +| ----------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | --------------------------------------------- | +| Middleware | [xref\:PatternKit.Behavioral.Strategy.ActionStrategy\`1](xref:PatternKit.Behavioral.Strategy.ActionStrategy`1) | First matching **action** runs (no return). | Logging, metrics, auth *messages*, CORS, etc. | +| Routing | [xref\:PatternKit.Behavioral.Strategy.Strategy\`2](xref:PatternKit.Behavioral.Strategy.Strategy`2) | First matching **handler** returns a value. | Pick a `Response` for the request. | +| Negotiation | [xref\:PatternKit.Behavioral.Strategy.TryStrategy\`2](xref:PatternKit.Behavioral.Strategy.TryStrategy`2) | Chain **try** handlers until one succeeds. | Fill in `ContentType` if missing. | + +All three are allocation-light, immutable once built, and thread-safe to execute. + +--- + +## Testing with TinyBDD + +We ship BDD-style tests in `PatternKit.Examples.Tests.ApiGateway`. + +### Health check + +```csharp +await Given("a default router", DefaultRouter) + .When("GET /health", r => r.Handle(new Request("GET", "/health", Headers()))) + .Then("status is 200", res => res.StatusCode == 200) + .And("content-type is text/plain", res => res.ContentType.StartsWith("text/plain")) + .AssertPassed(); +``` + +### Middleware first-match wins + +```csharp +var hits = new List(); + +MiniRouter Build() + => MiniRouter.Create() + .Use(static (in r) => r.Path.StartsWith("/a"), (in _) => hits.Add("A")) + .Use(static (in r) => r.Path.StartsWith("/a"), (in _) => hits.Add("B")) // also matches but won't run + .Map(static (in _) => true, static (in _) => Responses.Text(200, "ok")) + .NotFound(static (in _) => Responses.NotFound()) + .Build(); + +await Given("two matching middleware", Build) + .When("GET /a", r => r.Handle(new Request("GET", "/a", Headers()))) + .Then("exactly one ran", _ => hits.Count == 1 && hits[0] == "A") + .AssertPassed(); +``` + +### Content negotiation + +```csharp +await Given("negotiating router", NegotiatingRouter) + .When("GET /neg with Accept: text/plain", + r => r.Handle(new Request("GET", "/neg", Headers(accept: "text/plain")))) + .Then("content-type is text/plain", res => res.ContentType.StartsWith("text/plain")) + .AssertPassed(); +``` + +--- + +## Extending MiniRouter + +* **Add middleware**: `.Use(predicate, action)` — only the **first** matching action runs. +* **Add routes**: `.Map(predicate, handler)` — only the **first** matching handler returns. +* **Change NotFound**: `.NotFound(handler)` — default when nothing matches. +* **Swap negotiator**: `.WithNegotiator(tryStrategy)` — e.g., add `application/xml`. + +**Tip:** Prefer **static method groups** or **static lambdas** for zero-capture delegates and better allocations. + +--- + +## Performance notes + +* By-`in` parameters on strategies avoid defensive copies for structs. +* Static lambdas (`static (in r) => ...`) prevent hidden captures/allocations. +* Immutable, pre-built pipelines are **thread-safe**; builders are not. + +--- + +## Troubleshooting + +* **Multiple middleware actions run** + Ensure your conditions don’t both match earlier branches. **First match wins**; later branches are skipped only if an earlier branch matched. + +* **Route not hit** + Check earlier `.Map` conditions—an earlier, broader predicate may be capturing the request. + +* **Missing content-type** + If a handler returns `Response` with an empty `ContentType`, the negotiator fills it. Provide your own via `.WithNegotiator(...)` if defaults don’t suit. + +--- + +## API reference + +* [xref\:PatternKit.Examples.ApiGateway.MiniRouter](xref:PatternKit.Examples.ApiGateway.MiniRouter) + + * [xref\:PatternKit.Examples.ApiGateway.MiniRouter.Create\*](xref:PatternKit.Examples.ApiGateway.MiniRouter.Create*) + * [xref\:PatternKit.Examples.ApiGateway.MiniRouter.Handle\*](xref:PatternKit.Examples.ApiGateway.MiniRouter.Handle*) + * [xref\:PatternKit.Examples.ApiGateway.MiniRouter.Builder](xref:PatternKit.Examples.ApiGateway.MiniRouter.Builder) +* [xref\:PatternKit.Examples.ApiGateway.Request](xref:PatternKit.Examples.ApiGateway.Request) / [xref\:PatternKit.Examples.ApiGateway.Response](xref:PatternKit.Examples.ApiGateway.Response) / [xref\:PatternKit.Examples.ApiGateway.Responses](xref:PatternKit.Examples.ApiGateway.Responses) +* [xref\:PatternKit.Behavioral.Strategy.ActionStrategy\`1](xref:PatternKit.Behavioral.Strategy.ActionStrategy`1) +* [xref\:PatternKit.Behavioral.Strategy.Strategy\`2](xref:PatternKit.Behavioral.Strategy.Strategy`2) +* [xref\:PatternKit.Behavioral.Strategy.TryStrategy\`2](xref:PatternKit.Behavioral.Strategy.TryStrategy`2) + +--- + +### Appendix: End-to-end demo + +Run: + +```csharp +PatternKit.Examples.ApiGateway.Demo.Run(); +``` + +It prints the sequence described above (health, users/42, users/abc, admin unauthorized with a middleware log line first, POST /users, and 404 for /nope). diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml new file mode 100644 index 0000000..7d1928a --- /dev/null +++ b/docs/examples/toc.yml @@ -0,0 +1,20 @@ +- name: Examples & Demos + href: index.md + +- name: Auth & Logging with `ActionChain` + href: auth-logging-chain.md + +- name: Strategy-Based Data Coercion + href: coercer.md + +- name: Composed, Preference-Aware Notification Strategy (Email/SMS/Push/IM) + href: composed-notification-strategy.md + +- name: Mediated Transaction Pipeline + href: mediated-transaction-pipeline.md + +- name: Configuration-Driven Transaction Pipeline + href: config-driven-transaction-pipeline.md + +- name: Minimal Web Request Router + href: mini-router.md \ No newline at end of file diff --git a/docs/patterns/behavioral/chain/actionchain.md b/docs/patterns/behavioral/chain/actionchain.md new file mode 100644 index 0000000..d94ac3b --- /dev/null +++ b/docs/patterns/behavioral/chain/actionchain.md @@ -0,0 +1,179 @@ +# Behavioral.Chain.ActionChain + +**ActionChain\** is a tiny, middleware-style pipeline for “branchless rule packs.” +Each step receives the current context and a `next` delegate; it can **continue** or **short-circuit**. + +Use it when you want *ordered rules* (logging, validation, pre-auth, pricing, etc.) without `if` ladders, and when you +need very explicit continue/stop semantics. + +--- + +## TL;DR + +```csharp +using PatternKit.Behavioral.Chain; + +var log = new List(); + +var chain = ActionChain.Create() + .When((in r) => r.Headers.ContainsKey("X-Request-Id")) + .ThenContinue(r => log.Add($"reqid={r.Headers["X-Request-Id"]}")) + + .When((in r) => r.Path.StartsWith("/admin", StringComparison.Ordinal) && + !r.Headers.ContainsKey("Authorization")) + .ThenStop(r => log.Add("deny: missing auth")) + + // Tail runs only if earlier steps called `next` + .Finally((in r, next) => + { + log.Add($"{r.Method} {r.Path}"); + next(in r); // terminal `next` is a no-op + }) + .Build(); + +chain.Execute(new HttpRequest("GET", "/health", new Dictionary())); +chain.Execute(new HttpRequest("GET", "/admin/metrics", new Dictionary())); +// => ["GET /health", "deny: missing auth", "GET /admin/metrics"] +``` + +--- + +## Why ActionChain? + +* **Linear, readable rule packs**: each rule says *when* it applies and *what* it does. +* **Strict stop by default**: if a handler doesn’t call `next`, the chain ends immediately. +* **Low overhead**: builds a single, composed delegate; `Execute` is just one call. +* **Perf-shaped**: `in` parameters avoid copies; no LINQ; minimal allocations after `Build()`. + +--- + +## Core API + +```csharp +public sealed class ActionChain +{ + public delegate void Next(in TCtx ctx); + public delegate void Handler(in TCtx ctx, Next next); + public delegate bool Predicate(in TCtx ctx); + + public void Execute(in TCtx ctx); + + public static Builder Create(); + + public sealed class Builder + { + // Always-run middleware (if continued) + public Builder Use(Handler handler); + + // Conditional block + public WhenBuilder When(Predicate predicate); + + // Tail handler (runs only if chain wasn’t short-circuited) + public Builder Finally(Handler tail); + + public ActionChain Build(); + } + + public sealed class WhenBuilder + { + // Run custom handler when predicate is true; else continue automatically + public Builder Do(Handler handler); + + // Run action and STOP the chain when predicate is true + public Builder ThenStop(Action action); + + // Run action and CONTINUE when predicate is true + public Builder ThenContinue(Action action); + } +} +``` + +### Semantics (important!) + +* **Strict stop**: Any handler can end the chain by not calling `next`. + This also **skips `Finally`**. If you truly need “always run,” split logging into a separate chain or ensure every + earlier step calls `next`. +* **Ordering matters**: handlers run in the order you register them. +* **`When(...).ThenContinue` vs `ThenStop`**: + + * `ThenContinue` executes the action and *always* calls `next`. + * `ThenStop` executes the action and *never* calls `next`. + +--- + +## Patterns you’ll use + +* **Auth gate + logging** (strict stop): + + ```csharp + var chain = ActionChain.Create() + .When(static (in r) => r.Path.StartsWith("/admin") && + !r.Headers.ContainsKey("Authorization")) + .ThenStop(r => audit.Deny(r)) // stop: no tail + .Finally((in r, next) => { audit.Seen(r); next(in r); }) + .Build(); + ``` + +* **Pre-authorization checks** (multiple early exits): + + ```csharp + var chain = ActionChain.Create() + .When(static (in c) => c.Items.Count == 0) + .ThenStop(c => c.Fail("empty-basket")) + .When(static (in c) => c.CustomerAge < 21 && c.Items.Any(i => i.AgeRestricted)) + .ThenStop(c => c.Fail("age")) + .Finally((in c, next) => { c.Pass("preauth-ok"); next(in c); }) + .Build(); + ``` + +* **Branchless rule packs** (totals/discounts): + + ```csharp + var totals = ActionChain.Create() + .Use(static (in c, next) => { c.RecomputeSubtotal(); next(in c); }) + .When(static (in c) => c.FirstTenderIsCash).ThenContinue(c => c.AddDiscount(0.02m, "cash")) + .When(static (in c) => c.HasLoyalty).ThenContinue(c => c.AddDiscount(0.05m, "loyalty")) + .Finally(static (in c, next) => { c.ComputeTax(); next(in c); }) + .Build(); + ``` + +--- + +## Testing with TinyBDD (spec-style) + +```csharp +await Given("a chain that denies /admin without auth", () => +{ + var log = new List(); + var chain = ActionChain.Create() + .When((in r) => r.Path.StartsWith("/admin") && + !r.Headers.ContainsKey("Authorization")) + .ThenStop(r => log.Add("deny")) + .Finally((in r, next) => { log.Add($"{r.Method} {r.Path}"); next(in r); }) + .Build(); + return (chain, log); +}) +.When("GET /admin no auth", s => { s.chain.Execute(new("GET","/admin",new Dictionary())); return s; }) +.Then("first line is deny", s => s.log[0] == "deny") +.And("no tail logged (strict stop)", s => s.log.Count == 1) +.AssertPassed(); +``` + +--- + +## Tips & gotchas + +* **Want tail to always run?** Don’t use `ThenStop` earlier, or move the “always-run” logic to a separate chain invoked + after this one. +* **Avoid captures**: copy locals (`var pred = _pred;`) like the builder does, to keep delegates non-allocating. +* **Use `in` everywhere**: it keeps hot-path costs low for structs and larger contexts. +* **Compose freely**: you can wrap chains into stages (e.g., a transaction pipeline), or put chains behind higher-level + builders. + +--- + +## See also + +* [ResultChain](./resultchain.md) – like ActionChain, but steps return a result and short-circuit on failure. +* [BranchBuilder](../../creational/builder/branchbuilder.md) – zero-`if` router (predicate → step) for first-match-wins dispatch. +* [Strategy](../strategy/strategy.md) / [TryStrategy](../strategy/trystrategy.md) – single-choice or first-success selection for handlers/parsers. diff --git a/docs/patterns/behavioral/chain/resultchain.md b/docs/patterns/behavioral/chain/resultchain.md new file mode 100644 index 0000000..e203153 --- /dev/null +++ b/docs/patterns/behavioral/chain/resultchain.md @@ -0,0 +1,172 @@ +# Result Chain (`Behavioral.Chain.ResultChain`) + +A **first–match-wins** chain that can *produce a value*. +Each handler receives `(in TIn input, out TOut? result, Next next)` and can either: + +* **Produce** a result and return `true` → the chain short-circuits, or +* **Delegate** to `next(input, out result)` → the chain continues. + +It’s the “router with a return value” sibling of [`ActionChain`](./actionchain.md). + +--- + +## Why use `ResultChain`? + +Use it when you need **ordered rules that return something**: + +* HTTP‐ish routing → `HttpResponse` +* Command parsers → `ICommand` +* Promotion/price/feature selection → `CalculationResult` +* Fallback/“NotFound” defaults via a terminal tail + +If you only need **side effects**, prefer `ActionChain`. +If you want a simpler **first match mapping** (no `next`), see `Strategy`/`TryStrategy`. + +--- + +## Quick example: tiny router + +```csharp +using PatternKit.Behavioral.Chain; + +public readonly record struct Request(string Method, string Path); +public readonly record struct Response(int Status, string Body); + +var router = ResultChain.Create() + // GET /health + .When(static (in r) => r.Method == "GET" && r.Path == "/health") + .Then(r => new Response(200, "OK")) + // GET /users/{id} + .When(static (in r) => r.Method == "GET" && r.Path.StartsWith("/users/")) + .Then(r => new Response(200, $"user:{r.Path[7..]}")) + // default / not found + .Finally(static (in _, out Response? res, _) => { res = new(404, "not found"); return true; }) + .Build(); + +var ok1 = router.Execute(in new Request("GET", "/health"), out var res1); // ok1=true, res1=200 OK +var ok2 = router.Execute(in new Request("GET", "/nope"), out var res2); // ok2=true, res2=404 +``` + +### “Sometimes produce, sometimes delegate” + +Use `When(...).Do(handler)` when a rule might *conditionally* produce and otherwise pass control onward: + +```csharp +var chain = ResultChain.Create() + .When(static (in x) => x % 2 == 0).Do(static (in x, out string? r, ResultChain.Next next) => + { + // Only handle THE answer; otherwise delegate + if (x == 42) { r = "forty-two"; return true; } + return next(in x, out r); + }) + .When(static (in x) => x % 2 == 0).Then(_ => "even") // handles delegated evens + .Finally(static (in _, out string? r, _) => { r = "odd"; return true; }) + .Build(); +``` + +--- + +## API surface + +```csharp +public sealed class ResultChain +{ + public delegate bool Next(in TIn input, out TOut? result); + public delegate bool TryHandler(in TIn input, out TOut? result, Next next); + public delegate bool Predicate(in TIn input); + + public bool Execute(in TIn input, out TOut? result); + + public sealed class Builder + { + public Builder Use(TryHandler handler); + public WhenBuilder When(Predicate predicate); + public Builder Finally(TryHandler tail); // terminal fallback + public ResultChain Build(); + + public sealed class WhenBuilder + { + public Builder Do(TryHandler handler); // may produce OR delegate + public Builder Then(Func produce); // produces and stops + } + } + + public static Builder Create(); +} +``` + +### Semantics + +* **Order is preserved.** First matching producer wins. +* **`Then(Func)`**: if predicate is true → produce result, return `true`. +* **`Do(TryHandler)`**: you decide to produce or delegate by calling `next`. +* **`Finally(TryHandler)`**: runs *only* if the chain reaches the tail (i.e., nobody produced earlier). + Typical use: default/NotFound. +* **`Execute(...)`** returns: + + * `true` when any handler (or `Finally`) produced a result; `result` is set. + * `false` when no one produced and no `Finally` ran; `result` is `default`. + +--- + +## Patterns + +### 1) Default/NotFound tail + +```csharp +.Finally(static (in _, out MyResult? r, _) => { r = MyResult.NotFound; return true; }) +``` + +### 2) Multi-stage processing + +Chain “can I handle this?” steps. Each step may produce, or else delegate: + +```csharp +.When(static (in x) => IsFastPath(x)).Do(static (in x, out R? r, var next) => +{ + if (TryFast(x, out r)) return true; + return next(in x, out r); // let later handlers try +}) +.When(static (in x) => IsSlowPath(x)).Then(SlowCompute) +``` + +### 3) Cross-cut logging without duplication + +Put it in `Finally` **only if** you want it to run when nothing matched. +Otherwise log inside the `Then/Do` that produced. + +--- + +## Performance & threading + +* The chain composes to a **single delegate** at `Build()` time (reverse fold). + No allocations during `Execute` besides what your handlers do. +* The built chain is **immutable** and **thread-safe**. Builders are **not** thread-safe. + +--- + +## Gotchas & tips + +* For lambdas passed to `When(...)`, the parameter is **`in`**. + Prefer explicit static lambdas to avoid captures and compiler warnings: + + ```csharp + .When(static (in r) => r.Flag) // good + .Then(static r => ...) // Then’s lambda is a normal parameter (no `in`) + ``` + +* If you omit `Finally` and nothing produces, `Execute` returns `false` and `result` is `default`. + +--- + +## Tests + +See `PatternKit.Tests/Behavioral/Chain/ResultChainTests.cs` for executable specs covering: + +* First match wins; fallback via `Finally` +* No tail → `Execute` returns `false` +* `When.Do` → produce vs delegate +* Registration order guarantees +* Tail runs only when no earlier producer + +These tests use **TinyBDD** so the assertions read like documentation. diff --git a/docs/patterns/behavioral/strategy/actionstrategy.md b/docs/patterns/behavioral/strategy/actionstrategy.md new file mode 100644 index 0000000..2a56158 --- /dev/null +++ b/docs/patterns/behavioral/strategy/actionstrategy.md @@ -0,0 +1,153 @@ +# ActionStrategy\ + +A tiny, **first-match-wins** strategy where each branch runs a **side-effecting action** (no return value). Think of it as the “actions-only” sibling of `Strategy` / `TryStrategy`. + +* **Input:** `in TIn` +* **Match:** first predicate that returns `true` +* **Action:** runs once (no fallthrough) +* **Fallback:** optional default action +* **APIs:** `Execute(in TIn)` (throws if nothing matches and no default), `TryExecute(in TIn)` (never throws; returns `true/false`) + +--- + +## When to use + +Use `ActionStrategy` when you need exactly one of several **procedures** to run for a given input—logging, routing to an action that doesn’t produce a value, selecting a handler, etc. If you need to **compute a result**, use `Strategy` / `TryStrategy` instead. + +--- + +## Quick start + +```csharp +using PatternKit.Behavioral.Strategy; + +var log = new List(); + +var s = ActionStrategy.Create() + .When(static (in i) => i > 0).Then(static (in i) => log.Add($"+{i}")) + .When(static (in i) => i < 0).Then(static (in i) => log.Add($"{i}")) + .Default(static (in _) => log.Add("zero")) + .Build(); + +s.Execute(5); // logs "+5" +s.Execute(-3); // logs "-3" +s.Execute(0); // logs "zero" +``` + +### No default vs default + +```csharp +var noDefault = ActionStrategy.Create() + .When(static (in i) => i % 2 == 0).Then(static (in i) => Console.WriteLine($"even:{i}")) + .Build(); + +noDefault.TryExecute(3); // false, nothing ran +// noDefault.Execute(3); // throws InvalidOperationException (no match, no default) + +var withDefault = ActionStrategy.Create() + .Default(static (in _) => Console.WriteLine("fallback")) + .Build(); + +withDefault.TryExecute(3); // true, wrote "fallback" +withDefault.Execute(3); // also writes "fallback" +``` + +--- + +## Builder API (at a glance) + +```csharp +var s = ActionStrategy.Create() + .When(Predicate).Then(ActionHandler) // add as many branches as you want + .Default(ActionHandler) // optional + .Build(); +``` + +* `When(Predicate)`: start a branch (predicate signature: `bool(in TIn)`). +* `Then(ActionHandler)`: action to run when the branch matches (`void(in TIn)`). +* `Default(ActionHandler)`: action to run when nothing matched. +* `Build()`: composes an immutable, thread-safe strategy. + +**Execution semantics** + +* Branches are evaluated in **registration order**. +* The **first** matching `When(...).Then(...)` runs; no later branches are considered. +* If nothing matched: + + * `Execute` runs **default** if present; otherwise throws. + * `TryExecute` returns **true** if default ran; otherwise **false**. + +--- + +## Ordering guarantees + +Registration order is preserved. Only the **first** matching action runs: + +```csharp +var log = new List(); +var s = ActionStrategy.Create() + .When(static (in i) => i % 2 == 0).Then(static (in _) => log.Add("first")) + .When(static (in i) => i >= 0).Then(static (in _) => log.Add("second")) + .Default(static (in _) => log.Add("default")) + .Build(); + +s.Execute(2); +Console.WriteLine(string.Join("|", log)); // "first" +``` + +--- + +## Error handling + +* `Execute(in TIn)` throws `InvalidOperationException` when **no** predicate matches **and** there is **no** default. +* `TryExecute(in TIn)` never throws due to “no match”; it simply returns `false`. + +--- + +## Performance & thread-safety + +* The builder compiles arrays of predicates/actions once at `Build()` time. +* The built strategy is **immutable** and **thread-safe** (you can cache and reuse). +* Delegates use `in` parameters to avoid defensive copies for structs. + +--- + +## Testing tips (TinyBDD snippet) + +Your unit tests can read like specs: + +```csharp +using TinyBDD; +using TinyBDD.Xunit; +using PatternKit.Behavioral.Strategy; + +public class ActionStrategySpec : TinyBddXunitBase +{ + [Fact] + public async Task first_match_and_default() + { + var log = new List(); + var s = ActionStrategy.Create() + .When(static (in i) => i > 0).Then((in i) => log.Add($"+{i}")) + .When(static (in i) => i < 0).Then((in i) => log.Add($"{i}")) + .Default(static (in _) => log.Add("zero")) + .Build(); + + await Given("a composed action strategy", () => s) + .When("executing 5", _ => { s.Execute(5); return 0; }) + .And("executing -3", _ => { s.Execute(-3); return 0; }) + .And("executing 0", _ => { s.Execute(0); return 0; }) + .Then("logs +5|-3|zero", _ => string.Join("|", log) == "+5|-3|zero") + .AssertPassed(); + } +} +``` + +--- + +## Related patterns + +* **Produces a value?** Use \[`Strategy`] or \[`TryStrategy`]. +* **Needs side effects with short-circuiting middleware style?** See \[`ActionChain`]. + +> All of these follow the same **first-match-wins** philosophy so you can compose small units without `if/else` tangles. diff --git a/docs/patterns/behavioral/strategy/asyncstrategy.md b/docs/patterns/behavioral/strategy/asyncstrategy.md new file mode 100644 index 0000000..e5716c8 --- /dev/null +++ b/docs/patterns/behavioral/strategy/asyncstrategy.md @@ -0,0 +1,162 @@ +# AsyncStrategy\ + +A **first-match-wins, asynchronous strategy**: evaluate predicates in order and execute the handler for the **first** +branch that matches. Handlers return a `TOut` via `ValueTask`. + +Great for things like: async routing/dispatch, picking a storage backend, selecting an algorithm or serializer, or any +“try A, else B” flow that needs awaits. + +--- + +## Why use it + +* **Deterministic control flow**: registration order = evaluation order; only the first match runs. +* **Async-first**: both predicates and handlers can await. +* **Explicit defaults**: optional fallback handler when nothing matches. +* **Immutable & thread-safe** after `Build()` (safe for concurrent calls). +* **Allocation-light**: uses arrays and `ValueTask` to minimize overhead. + +--- + +## TL;DR example + +```csharp +using PatternKit.Behavioral.Strategy; + +var strat = AsyncStrategy.Create() + .When((n, ct) => new ValueTask(n < 0)) + .Then((n, ct) => new ValueTask("negative")) + .When((n, ct) => new ValueTask(n == 0)) + .Then((n, ct) => new ValueTask("zero")) + .Default((n, ct) => new ValueTask("positive")) + .Build(); + +var s1 = await strat.ExecuteAsync(-5); // "negative" +var s2 = await strat.ExecuteAsync(0); // "zero" +var s3 = await strat.ExecuteAsync(7); // "positive" +``` + +--- + +## Building branches + +Each branch is a **predicate + handler** pair: + +```csharp +var s = AsyncStrategy.Create() + .When((req, ct) => new ValueTask(req.Path.StartsWith("/admin"))) + .Then(async (req, ct) => + { + await Audit(req, ct); + return await HandleAdmin(req, ct); + }) + .When((req, ct) => new ValueTask(req.Path.StartsWith("/api/"))) + .Then((req, ct) => HandleApi(req, ct)) // already returns ValueTask + .Default((req, ct) => NotFound(req)) // fallback runs if nothing matched + .Build(); +``` + +**First match wins**: if multiple predicates are `true`, only the earliest registered one runs. + +--- + +## Defaults & errors + +* If you provide **`.Default(handler)`**, it runs when no predicates match. +* If you **omit** a default and nothing matches, `ExecuteAsync` throws `InvalidOperationException` + to signal **no branch matched**. + +--- + +## Cancellation + +`CancellationToken` flows into both predicate and handler: + +```csharp +var s = AsyncStrategy.Create() + .When((_, ct) => + { + ct.ThrowIfCancellationRequested(); + return new ValueTask(true); + }) + .Then((_, ct) => + { + ct.ThrowIfCancellationRequested(); + return new ValueTask("ok"); + }) + .Build(); +``` + +If the token is canceled, your predicate/handler can throw `OperationCanceledException` and the call will surface it. + +--- + +## Synchronous adapters (nice for quick wiring) + +You don’t have to write `ValueTask` everywhere: + +```csharp +var s = AsyncStrategy.Create() + .When(n => n % 2 == 0) // sync predicate + .Then((_, _) => new ValueTask("even")) + .When((n, _) => new ValueTask(n >= 0)) + .Then((_, _) => new ValueTask("nonneg")) + .Default(_ => "other") // sync default + .Build(); + +await s.ExecuteAsync(2); // "even" +await s.ExecuteAsync(1); // "nonneg" +await s.ExecuteAsync(-1); // "other" +``` + +--- + +## Testing (TinyBDD style) + +```csharp +[Scenario("First matching async branch runs; default used when none match")] +[Fact] +public async Task FirstMatchAndDefault() +{ + var log = new List(); + var strat = AsyncStrategy.Create() + .When((n, _) => new ValueTask(n > 0)) + .Then((n, _) => { log.Add("pos"); return new ValueTask("+" + n); }) + .When((n, _) => new ValueTask(n < 0)) + .Then((n, _) => { log.Add("neg"); return new ValueTask(n.ToString()); }) + .Default((_, _) => new ValueTask("zero")) + .Build(); + + var r1 = await strat.ExecuteAsync(5); + Assert.Equal("+5", r1); + Assert.Equal("pos", string.Join("|", log)); +} +``` + +Our repository includes comprehensive tests covering: first-match behavior, default vs. throw, sync adapters, order +guarantees, and cancellation. + +--- + +## Design notes + +* **Built on `BranchBuilder`**: the builder collects `(Predicate, Handler)` pairs and compiles them into arrays. +* **`ValueTask` everywhere**: avoids allocating `Task` for already-completed operations. +* **No reflection / no LINQ** in the hot path: simple loops over arrays. + +--- + +## Gotchas + +* **Order matters.** Put the most specific predicates first. +* **Default is optional.** Without it, expect `InvalidOperationException` when nothing matches. +* **Predicate/handler exceptions** are not swallowed—let them surface or handle them upstream. + +--- + +## See also + +* [ActionStrategy](./actionstrategy.md) — first-match actions with no return value. +* [Strategy](./strategy.md) — synchronous result-producing strategy (throws on no match). +* [TryStrategy](./trystrategy.md) — synchronous, result-producing strategy that can “not match” without throwing. +* [BranchBuilder](../../creational/builder/branchbuilder.md) — the generic composition utility AsyncStrategy builds upon. diff --git a/docs/patterns/behavioral/strategy/strategy.md b/docs/patterns/behavioral/strategy/strategy.md new file mode 100644 index 0000000..984d873 --- /dev/null +++ b/docs/patterns/behavioral/strategy/strategy.md @@ -0,0 +1,159 @@ +# Strategy\ + +A **first-match-wins, synchronous strategy**: evaluate predicates in order and execute the handler for the **first** branch that matches. The chosen handler returns a `TOut`. + +Use it to replace `switch`/`if-else` cascades with a small, composable decision pipeline: routing, labelers, mappers, pick-an-algorithm, etc. + +--- + +## What it is + +* **Deterministic branching**: registration order = evaluation order; only the first match runs. +* **Return a value**: each handler produces a `TOut`. +* **Optional default**: a fallback handler when nothing matches; otherwise `Execute` throws. +* **Immutable & thread-safe** after `Build()`. + +> If you want a non-throwing variant, see **TryStrategy\**. +> If you only need side effects (no return), see **ActionStrategy\**. + +--- + +## TL;DR example + +```csharp +using PatternKit.Behavioral.Strategy; + +var classify = Strategy.Create() + .When(static i => i > 0).Then(static _ => "positive") + .When(static i => i < 0).Then(static _ => "negative") + .Default(static _ => "zero") + .Build(); + +var a = classify.Execute( 7); // "positive" +var b = classify.Execute(-3); // "negative" +var c = classify.Execute( 0); // "zero" +``` + +If you **omit** `.Default(...)` and nothing matches, `Execute` throws `InvalidOperationException` (via `Throw.NoStrategyMatched()`). + +--- + +## Building branches + +Each branch is a **predicate + handler** pair: + +```csharp +var chooseStorage = Strategy.Create() + .When(static path => path.StartsWith("s3:", StringComparison.Ordinal)) + .Then(static _ => new S3BlobStore()) + .When(static path => path.StartsWith("gs:", StringComparison.Ordinal)) + .Then(static _ => new GcsBlobStore()) + .Default(static _ => new FileSystemBlobStore()) + .Build(); +``` + +**First match wins.** If more than one predicate is `true`, only the earliest one runs. + +--- + +## Typical patterns + +### 1) Simple mapping / labeling + +```csharp +var label = Strategy.Create() + .When(static n => (n & 1) == 0).Then(static _ => "even") + .When(static n => n % 3 == 0).Then(static _ => "div3") + .Default(static _ => "other") + .Build(); +``` + +### 2) Content negotiation (sync) + +```csharp +var pickWriter = Strategy.Create() + .When(static ct => ct == "application/json").Then(static _ => new JsonWriter()) + .When(static ct => ct == "text/csv").Then(static _ => new CsvWriter()) + .Default(static _ => new TextWriter()) + .Build(); +``` + +### 3) Rule packs with “most specific first” + +```csharp +var price = Strategy.Create() + .When(static it => it.OnSale).Then(static it => it.BasePrice * 0.8m) + .When(static it => it.IsWholesale).Then(static it => it.BasePrice * 0.9m) + .Default(static it => it.BasePrice) + .Build(); +``` + +--- + +## API shape + +```csharp +var s = Strategy.Create() + .When(static (in TIn x) => /* bool */).Then(static (in TIn x) => /* TOut */) + .Default(static (in TIn x) => /* TOut */) // optional + .Build(); + +TOut result = s.Execute(in input); // throws if no match and no default +``` + +* **`When(predicate).Then(handler)`**: registers a branch. +* **`.Default(handler)`**: sets the fallback when no predicates match. +* **`Execute(in TIn)`**: runs the first matching handler or the default; throws if neither exists. + +All delegates accept `in TIn` for zero-copy pass-through of structs. + +--- + +## Testing (TinyBDD style) + +```csharp +[Scenario("First-match wins; default runs when none match")] +[Fact] +public async Task Strategy_FirstMatch_Default() +{ + var strat = Strategy.Create() + .When(static i => i > 0).Then(static _ => "pos") + .When(static i => i < 0).Then(static _ => "neg") + .Default(static _ => "zero") + .Build(); + + await Given("the strategy", () => strat) + .When("Execute(3)", s => s.Execute(3)) + .Then("is 'pos'", r => r == "pos") + .When("Execute(-2)", s => strat.Execute(-2)) + .Then("is 'neg'", r => r == "neg") + .When("Execute(0)", s => strat.Execute(0)) + .Then("is 'zero'", r => r == "zero") + .AssertPassed(); +} +``` + +--- + +## Design notes + +* **No LINQ / reflection in the hot path** — predicates/handlers are arrays iterated with a simple `for` loop. +* **Immutability** — after `Build()` the strategy can be shared across threads. +* **Order matters** — put the most specific predicates first. + +--- + +## Gotchas + +* **No default + no match ⇒ throw.** Use **TryStrategy\** if you want a non-throwing “no match” path (`bool Execute(in, out TOut?)`). +* **Side effects?** Prefer **ActionStrategy\** when you only need actions and no return value. +* **Async?** Use **AsyncStrategy\** when your predicates/handlers await. + +--- + +## See also + +* [TryStrategy](./trystrategy.md) — first-match with `bool` success + `out` result (no throw on no match). +* [ActionStrategy](./actionstrategy.md) — first-match, side-effect only (no result). +* [AsyncStrategy](./asyncstrategy.md) — first-match with async handlers returning `ValueTask`. +* [BranchBuilder](../../creational/builder/branchbuilder.md) — the low-level composer used by all strategies. diff --git a/docs/patterns/behavioral/strategy/trystrategy.md b/docs/patterns/behavioral/strategy/trystrategy.md new file mode 100644 index 0000000..395c753 --- /dev/null +++ b/docs/patterns/behavioral/strategy/trystrategy.md @@ -0,0 +1,69 @@ +# TryStrategy + +A **first-success, non-throwing strategy**: evaluate a sequence of `Try` handlers in order until one succeeds. Each handler attempts to produce a `TOut` and returns `true` on success (setting the `out` value) or `false` to let the next handler try. + +Use `TryStrategy` when you want a safe, allocation-light parsing/coercion pipeline (e.g., `Coercer`) or any "try A, else B" flow where failures are expected and should not throw. + +--- + +## TL;DR + +```csharp +var parser = TryStrategy.Create() + .Always((in string s, out int r) => int.TryParse(s, out r)) + .Finally((in string _, out int r) => { r = 0; return true; }) + .Build(); + +if (parser.Execute("123", out var n)) Console.WriteLine(n); // 123 +``` + +`Execute(in, out)` returns `true` when a handler produced a value; otherwise `false` and `out` is `default` (unless the optional `Finally` provided a fallback). + +--- + +## What it is + +* **First-success wins**: handlers are tried in registration order; the first that returns `true` wins. +* **Non-throwing**: `Execute` signals success via a `bool`, never throws just because no handler matched. +* **Low-cost hot path**: handlers are compiled into arrays and iterated in a simple `for` loop. + +--- + +## API shape + +```csharp +var b = TryStrategy.Create() + .Always(TryHandler) // append a handler that may succeed + .Finally(TryHandler) // optional fallback (always runs if provided) + .Build(); + +bool ok = b.Execute(in input, out TOut? result); +``` + +* `Always(TryHandler)` (or `.When(...).ThenTry(...)` in some builders) registers attempts. +* `Finally(TryHandler)` provides a guaranteed fallback; callers can still use the boolean result to detect whether a "real" handler succeeded. + +--- + +## Typical patterns + +* **Coercion / parsing**: chain a set of type-specific parsers, ending with a convertible fallback. See `Coercer`. +* **Content negotiation**: try several negotiators until one reports `true` and sets a content type. +* **Loose deserialization**: try JSON, then CSV, then plain string parsing. + +--- + +## Gotchas + +* Handlers must not swallow exceptions you want surfaced; if a handler throws, the exception will propagate unless you explicitly catch inside the handler. +* Registration order matters: put the fastest and most-specific handlers first. + +--- + +## See also + +* [Strategy](./strategy.md) — first-match that returns a `TOut` and throws when nothing matches. +* [ActionStrategy](./actionstrategy.md) — first-match actions with no return value. +* [AsyncStrategy](./asyncstrategy.md) — async first-match strategy. +* [BranchBuilder](../../creational/builder/branchbuilder.md) — the low-level composer used by strategy families. + diff --git a/docs/patterns/creational/builder/branchbuilder.md b/docs/patterns/creational/builder/branchbuilder.md new file mode 100644 index 0000000..cd1fb3e --- /dev/null +++ b/docs/patterns/creational/builder/branchbuilder.md @@ -0,0 +1,152 @@ +# BranchBuilder\ + +A tiny, reusable builder for collecting **predicate/handler pairs** (plus an optional **default**) and projecting them into any concrete “strategy-like” product. It’s the core used by `ActionStrategy`, `Strategy`, and `AsyncStrategy`. + +--- + +## Why it exists + +Lots of “first-match-wins” constructs look the same: you register ordered predicate/handler pairs, optionally set a default, then build an immutable thing. `BranchBuilder` captures that pattern so you can: + +* Avoid re-implementing the same plumbing. +* Keep allocations minimal (lists while building, single `ToArray()` on `Build()`). +* Project the collected data into any product type via a **projector** function. + +--- + +## Mental model + +* **Registration order matters.** The `i`th predicate corresponds to the `i`th handler. +* **Default is optional.** If you don’t set one, a **fallback** you supply at build time is used, and you get a `hasDefault=false` flag. +* **Build is a snapshot.** Each call copies to arrays; later calls don’t mutate earlier products. + +--- + +## API at a glance + +```csharp +var b = BranchBuilder.Create(); + +b.Add(TPred predicate, THandler handler); // append a pair (order preserved) +b.Default(THandler handler); // set/replace default + +TProduct product = b.Build( + fallbackDefault: THandler, // used if no Default() was configured + projector: (TPred[] preds, + THandler[] handlers, + bool hasDefault, + THandler @default) => /* construct product */ +); +``` + +### Threading & immutability + +* Builders are **not** thread-safe. +* Arrays passed to your projector are **fresh snapshots**. Treat them as immutable in your product. + +--- + +## Minimal examples + +### 1) Build a simple classifier (sync) + +```csharp +// Shapes +delegate bool Pred(in int x); +delegate string Handler(in int x); + +// Predicates/handlers +static bool IsEven(in int x) => (x & 1) == 0; +static bool IsPositive(in int x) => x > 0; +static string HandleEven(in int _) => "even"; +static string HandlePositive(in int _) => "pos"; +static string Fallback(in int _) => "other"; + +sealed record Classifier(Pred[] Preds, Handler[] Handlers, bool HasDefault, Handler Default) +{ + public string Execute(in int x) + { + for (var i = 0; i < Preds.Length; i++) + if (Preds[i](in x)) return Handlers[i](in x); + return Default(in x); + } +} + +var classifier = + BranchBuilder.Create() + .Add(IsEven, HandleEven) + .Add(IsPositive, HandlePositive) + .Build(fallbackDefault: Fallback, + projector: (p, h, hasDef, def) => new Classifier(p, h, hasDef, def)); + +classifier.Execute(2); // "even" +classifier.Execute(1); // "pos" +classifier.Execute(-1); // "other" (fallback) +``` + +### 2) Swap in a real default (not fallback) + +```csharp +static string RealDefault(in int _) => "default"; + +var withRealDefault = + BranchBuilder.Create() + .Add(IsEven, HandleEven) + .Default(RealDefault) + .Build(Fallback, (p, h, hasDef, def) => new Classifier(p, h, hasDef, def)); + +// withRealDefault.HasDefault == true; withRealDefault.Default == RealDefault +``` + +### 3) What the built-in strategies do + +All of these are thin wrappers over `BranchBuilder`: + +* `ActionStrategy` → predicates + **action** handlers (`void`). +* `Strategy` → predicates + **result** handlers (`TOut`). +* `AsyncStrategy` → async predicates/handlers (`ValueTask`). + +Each supplies a sensible **fallback default** to `Build(...)` and a projector that constructs the immutable strategy. + +--- + +## Usage patterns & tips + +* **Replace defaults:** calling `Default(...)` multiple times replaces the previous one (“last wins”). +* **Conditional registration:** gate calls to `.Add(...)` with your own `if` or feature flags. (The conditional DSL lives in `TryStrategy`; `BranchBuilder` stays simple.) +* **Multiple products from one builder:** you can call `Build(...)` more than once. Each build snapshots current pairs and default. +* **Interop with `in` parameters:** Using `in` in your delegate shapes keeps handlers low-overhead for structs. + +--- + +## Gotchas + +* **No validation of shapes.** `TPred`/`THandler` are just types; ensure they’re the right delegates for your projector. +* **Default semantics:** If you never call `Default(...)`, your projector receives `hasDefault=false` and the **fallback** handler as `@default`. Use the flag to distinguish “user configured” vs “library fallback”. + +--- + +## Reference (public API) + +```csharp +public sealed class BranchBuilder +{ + public static BranchBuilder Create(); + + public BranchBuilder Add(TPred predicate, THandler handler); + public BranchBuilder Default(THandler handler); + + public TProduct Build( + THandler fallbackDefault, + Func projector); +} +``` + +--- + +## See also + +* [ActionStrategy](../../behavioral/strategy/actionstrategy.md) – first-match actions. +* [Strategy](../../behavioral/strategy/strategy.md) – first-match handlers that return values. +* [AsyncStrategy](../../behavioral/strategy/asyncstrategy.md) – async first-match strategy. +* [ActionChain](../../behavioral/chain/actionchain.md) / [ResultChain](../../behavioral/chain/resultchain.md) – chain style (middleware) alternatives. diff --git a/docs/patterns/creational/builder/chainbuilder.md b/docs/patterns/creational/builder/chainbuilder.md new file mode 100644 index 0000000..dc841e5 --- /dev/null +++ b/docs/patterns/creational/builder/chainbuilder.md @@ -0,0 +1,120 @@ +# ChainBuilder\ + +A tiny, allocation-light builder that collects items **in order** and then **projects** them into any product type. It’s the backbone for “append things, then freeze into an immutable structure” scenarios (e.g., composing pipelines). + +--- + +## Mental model + +* **Append-only order.** `Add` pushes to the end; order is preserved. +* **Conditional append.** `AddIf(cond, item)` only appends when `cond` is `true`. +* **Snapshot on build.** `Build(projector)` copies items to a fresh array and passes it to your projector. Subsequent `Add` calls don’t mutate previously built products. + +--- + +## API at a glance + +```csharp +var b = ChainBuilder.Create(); + +b.Add(T item); // append item +b.AddIf(bool condition, T); // append only when condition is true + +TProduct product = b.Build(items => /* construct product from T[] */); +``` + +### Threading & immutability + +* Builders are **not** thread-safe. +* `Build` hands you a **new array snapshot** each time—treat it as immutable in your product. + +--- + +## Minimal examples + +### 1) Make a simple CSV projector + +```csharp +var csv = ChainBuilder.Create() + .Add(1) + .Add(2) + .AddIf(false, 99) // ignored + .Build(items => string.Join(",", items)); +// "1,2" +``` + +### 2) Build and reuse with snapshots + +```csharp +var b = ChainBuilder.Create().Add(1).Add(2); + +var first = b.Build(items => items.Length); // 2 +b.Add(3); +var second = b.Build(items => items.Length); // 3 + +// 'first' used the earlier snapshot; wasn't mutated by Add(3) +``` + +### 3) Compose a middleware delegate (handlers list → single runner) + +```csharp +// Handler is (in TCtx ctx, Next next) => void +public delegate void Handler(in TCtx ctx, Action next); + +var handlers = ChainBuilder>.Create() + .Add((in int x, next) => { Console.Write("[A]"); next(in x); }) + .Add((in int x, next) => { Console.Write("[B]"); next(in x); }) + .Build(items => + { + // compose from end to start + Action next = static (in _) => { }; + for (var i = items.Length - 1; i >= 0; i--) + { + var h = items[i]; + var prev = next; + next = (in int c) => h(in c, prev); + } + return next; + }); + +handlers(in 0); // prints [A][B] +``` + +--- + +## Usage patterns & tips + +* **Feature-flagged registration:** wrap `AddIf(flag, item)` to keep builder clutter-free. +* **Multiple products from one builder:** call `Build` multiple times with different projectors (e.g., build a runner and a debug view). +* **Low overhead:** Lists while building; exactly one `ToArray()` per `Build`. + +--- + +## Gotchas + +* **No removal/reorder.** It’s purposefully simple—append in the order you want to execute. +* **Projector owns semantics.** `ChainBuilder` doesn’t interpret items; your projector decides what they mean. + +--- + +## Reference (public API) + +```csharp +public sealed class ChainBuilder +{ + public static ChainBuilder Create(); + + public ChainBuilder Add(T item); + public ChainBuilder AddIf(bool condition, T item); + + public TProduct Build(Func projector); +} +``` + +--- + +## See also + +* [BranchBuilder](./branchbuilder.md) – collect predicate/handler pairs + optional default, then project. +* [Behavioral.Chain.ActionChain](../../behavioral/chain/actionchain.md) / [Behavioral.Chain.ResultChain](../../behavioral/chain/resultchain.md) – real pipelines built atop these patterns. +* [Behavioral.Strategy.TryStrategy](../../behavioral/strategy/trystrategy.md) – uses `ChainBuilder` for first-success execution. diff --git a/docs/patterns/creational/builder/composer.md b/docs/patterns/creational/builder/composer.md new file mode 100644 index 0000000..6c47234 --- /dev/null +++ b/docs/patterns/creational/builder/composer.md @@ -0,0 +1,153 @@ +# Composer\ + +A tiny, explicit **functional** builder: you accumulate immutable state (usually a small struct) via **pure transformations**, optionally add **validations**, then **project** the final state into your output type. + +--- + +## Mental model + +* **Seed → Transform → Validate → Project.** +* `With` composes functions **left-to-right** (i.e., `b(a(seed))`). +* `Require` chains validators; the **first non-null message** throws. +* Nothing happens until `Build` — that’s when transforms and validators run. + +--- + +## API at a glance + +```csharp +// Create with a seed factory (prefer static to avoid captures) +var c = Composer.New(static () => default); + +// Add transforms (pure functions State -> State) +c.With(static s => /* change s */); + +// Add validators (State -> string?); return null when OK +c.Require(static s => /* message-or-null */); + +// Finish: transform final State into your output type +Dto dto = c.Build(static s => new Dto(/* from s */)); +``` + +### Threading & immutability + +* The composer instance is mutable **until** `Build`. You can keep calling `With`/`Require` and `Build` repeatedly. +* The *state* you produce should be treated as immutable; prefer small `record struct`s for perf. + +--- + +## Minimal examples + +### 1) Basic composition + +```csharp +public readonly record struct PersonState(string? Name, int Age); +public sealed record PersonDto(string Name, int Age); + +var dto = Composer + .New(static () => default) // (Name=null, Age=0) + .With(static s => s with { Name = "Ada" }) + .With(static s => s with { Age = 30 }) + .Require(static s => string.IsNullOrWhiteSpace(s.Name) ? "Name is required." : null) + .Build(static s => new PersonDto(s.Name!, s.Age)); +// -> PersonDto("Ada", 30) +``` + +### 2) Left-to-right transform order + +```csharp +static PersonState A(PersonState s) => s with { Age = 10 }; +static PersonState B(PersonState s) => s with { Age = 20 }; + +var dto = Composer + .New(static () => default) + .With(A) // sets Age to 10 + .With(B) // then overrides to 20 + .Require(static _ => null) + .Build(static s => new PersonDto(s.Name ?? "?", s.Age)); +// Age == 20 +``` + +### 3) Multiple validators (first failure wins) + +```csharp +static string? NameRequired(PersonState s) + => string.IsNullOrWhiteSpace(s.Name) ? "Name is required." : null; + +static string? AgeInRange(PersonState s) + => s.Age is < 0 or > 130 ? $"Age must be within [0..130] but was {s.Age}." : null; + +var ex = Assert.Throws(() => + Composer.New(static () => new(null, -5)) + .Require(NameRequired) // fails first -> throws this message + .Require(AgeInRange) + .Build(static s => new PersonDto(s.Name!, s.Age))); +Assert.Equal("Name is required.", ex.Message); +``` + +### 4) Reuse a composer + +```csharp +var comp = Composer + .New(static () => default) + .With(static s => s with { Name = "Ada" }) + .Require(static _ => null); + +var dto1 = comp.Build(static s => new PersonDto(s.Name!, s.Age)); // ("Ada", 0) +var dto2 = comp.With(static s => s with { Age = 30 }) + .Build(static s => new PersonDto(s.Name!, s.Age)); // ("Ada", 30) +``` + +--- + +## Patterns & tips + +* **Prefer method pointers** over capturing lambdas for AOT/JIT friendliness: + + ```csharp + static PersonState SetName(PersonState s, string n) => s with { Name = n }; + c.With(static s => SetName(s, "Ada")); + ``` +* **Validation as composition**: chain small rules with `Require`; return `null` on success. +* **One projection, many outputs**: You can build multiple outputs by calling `Build` with different projectors. +* **No side effects in transforms**: keep `With` pure (deterministic, no I/O) for easy reasoning and testing. + +--- + +## Error handling + +* `Build` throws `InvalidOperationException` with the **first** validation message that is not `null`/empty. +* If there are no validators, `Build` always succeeds. + +--- + +## Performance notes + +* `With` composes delegates; composition cost is O(#With) executed once per `Build`. +* Use small, shallow `record struct` state to minimize copying. +* Prefer `static` lambdas / method groups to avoid allocations from captures. + +--- + +## Reference (public API) + +```csharp +public sealed class Composer +{ + public static Composer New(Func seed); + + public Composer With(Func transform); + + public Composer Require(Func validate); + + public TOut Build(Func project); +} +``` + +--- + +## See also + +* [ChainBuilder](./chainbuilder.md) – collect items, project to a product. +* [BranchBuilder](./branchbuilder.md) – collect predicate/handler pairs + optional default. +* [Strategy](../../behavioral/strategy/strategy.md) / [TryStrategy](../../behavioral/strategy/trystrategy.md) / [AsyncStrategy](../../behavioral/strategy/asyncstrategy.md) – consumers of these creational patterns. diff --git a/docs/patterns/creational/builder/mutablebuilder.md b/docs/patterns/creational/builder/mutablebuilder.md new file mode 100644 index 0000000..f87570d --- /dev/null +++ b/docs/patterns/creational/builder/mutablebuilder.md @@ -0,0 +1,98 @@ +# MutableBuilder\ + +A small, allocation-light builder for creating and configuring mutable instances. Use `MutableBuilder` when you want to compose a sequence of in-place mutations and validations against instances produced by a factory, then produce the configured instance with a single `Build()` call. + +File: `docs/patterns/creational/builder/mutablebuilder.md` + +## TL;DR + +```csharp +var person = MutableBuilder + .New(static () => new Person()) + .With(p => p.Name = "Ada") + .With(p => p.Age = 30) + .Require(p => p.Name is not null && p.Name != "" ? null : "Name must be non-empty.") + .Build(); +``` + +## What it is + +MutableBuilder\ is a tiny DSL for: + +- collecting mutation actions (`With`), +- collecting validators that return an optional error message (`Require`), +- applying mutations in registration order to a fresh instance from a factory, +- failing fast on the first validation that returns a non-`null` message. + +It favors explicit, reflection-free configuration and is optimized for minimal allocations. Prefer `static` lambdas to avoid captured closures. + +## Key semantics + +- Registration order is preserved: mutations are executed in the order added. +- Validations run in the order registered during `Build()` and the first non-`null` message causes `Build()` to throw `InvalidOperationException` with that message. +- `Build()` calls the configured factory for each build; the builder can be reused to produce multiple instances (later builds reflect additional registered mutations/validators). +- Builders are not thread-safe. + +## API at a glance + +- `static MutableBuilder New(Func factory)` — create a builder that calls `factory()` for each `Build()`. +- `MutableBuilder With(Action mutation)` — append an in-place mutation. +- `MutableBuilder Require(Func validator)` — append a validator that returns `null` for success or an error message for failure. +- `T Build()` — create an instance via the factory, apply mutations, run validators, return the instance or throw `InvalidOperationException` on first validator failure. + +Extension-style conveniences (project-specific, common patterns): + +- `RequireNotEmpty(Func selector, string name)` — validate string properties are not empty. +- `RequireRange(Func selector, int min, int max, string name)` — inclusive numeric range validator. + +## Examples + +1) Simple configuration + +```csharp +var p = MutableBuilder + .New(static () => new Person()) + .With(p => p.Name = "Ada") + .With(p => p.Age = 30) + .Build(); // { Name = "Ada", Age = 30 } +``` + +2) Mutations applied in order + +```csharp +var b = MutableBuilder.New(() => new Person()) + .With(p => p.Steps.Add("A")) + .With(p => p.Steps.Add("B")); + +var first = b.Build(); // Steps == ["A","B"] +b.With(p => p.Steps.Add("C")); +var second = b.Build(); // Steps == ["A","B","C"] +``` + +3) Validation failure + +```csharp +var b = MutableBuilder.New(() => new Person()) + .With(p => p.Name = "") + .Require(p => string.IsNullOrEmpty(p.Name) ? "Name must be non-empty." : null); + +_ = Record.Exception(() => b.Build()); // InvalidOperationException with message +``` + +## Testing tips + +- Test mutation order by appending actions that record to a shared list on the instance. +- Test validation by registering multiple validators and asserting the first failing validator message is thrown. +- Verify builder reuse by calling `Build()` multiple times after registering additional mutations. + +## Why use it + +- Explicit, readable configuration in tests and factories. +- Predictable behavior: deterministic mutation and validation order. +- Low overhead: only lists during configuration and one factory call + validators per `Build()`. + +## Gotchas + +- Builders are mutable and not thread-safe. Freeze semantics are not provided — callers must avoid concurrent mutations. +- Prefer `static` lambdas to avoid closure allocations in hot paths. +- Validators must return `null` on success; any non-`null` string is treated as the error message returned to the caller via `InvalidOperationException`. \ No newline at end of file diff --git a/docs/patterns/index.md b/docs/patterns/index.md new file mode 100644 index 0000000..74d628b --- /dev/null +++ b/docs/patterns/index.md @@ -0,0 +1,70 @@ +# Patterns + +Welcome! This section is the **reference home** for PatternKit’s core building blocks. Each page explains the *why*, the *shape* (APIs), and gives a tiny snippet so you can drop the pattern straight into your codebase. + +If you’re looking for end-to-end, production-shaped demos, check the **Examples & Demos** section—those pages show these patterns working together (auth/logging chains, payment pipelines, router, coercer, etc.). + +--- + +## How these fit together + +* **Behavioral** patterns describe *what runs & when* (chains and strategies). +* **Creational** helpers build immutable, fast artifacts (routers, pipelines) from tiny delegates. +* Common themes: + + * **First-match wins** (predictable branching without `if` ladders). + * **Branchless rule packs** (`ActionChain` with `When/ThenContinue/ThenStop/Finally`). + * **Immutable after `Build()`** (thread-safe, allocation-light hot paths). + +--- + +## Behavioral + +### Chain + +* **[Behavioral.Chain.ActionChain](behavioral/chain/actionchain.md)** + Compose linear rule packs with explicit continue/stop semantics and an always-runs `Finally`. + +* **[Behavioral.Chain.ResultChain](behavioral/chain/resultchain.md)** + Like `ActionChain`, but each step returns a result; first failure short-circuits. + +### Strategy + +* **[Behavioral.Strategy.Strategy](behavioral/strategy/strategy.md)** + Simple strategy selection—pick exactly one handler. + +* **[Behavioral.Strategy.TryStrategy](behavioral/strategy/trystrategy.md)** + First-success wins: chain of `Try(in, out)` handlers; great for parsing/coercion. + +* **[Behavioral.Strategy.ActionStrategy](behavioral/strategy/actionstrategy.md)** + Fire one or more actions (no result value) based on predicates. + +* **[Behavioral.Strategy.AsyncStrategy](behavioral/strategy/asyncstrategy.md)** + Async sibling for strategies that await external work. + +--- + +## Creational (Builder) + +* **[Creational.Builder.BranchBuilder](creational/builder/branchbuilder.md)** + Zero-`if` router: register `(predicate → step)` pairs; emits a tight first-match loop. + +* **[Creational.Builder.ChainBuilder](creational/builder/chainbuilder.md)** + Small helper to accumulate steps, then project into your own pipeline type. + +* **[Creational.Builder.Composer](creational/builder/composer.md)** + Compose multiple builders/artifacts into a single product. + +* **[Creational.Builder.MutableBuilder](creational/builder/mutablebuilder.md)** + A lightweight base for fluent, mutable configuration objects. + +--- + +## Where to see them in action + +* **Auth & Logging Chain** — request-ID logging + strict auth short-circuit using `ActionChain`. +* **Strategy-Based Coercion** — `TryStrategy` turns mixed inputs into typed values. +* **Mediated / Config-Driven Transaction Pipelines** — chains + strategies for totals, rounding, tender routing. +* **Minimal Web Request Router** — `BranchBuilder` for middleware and routes. + +> Tip: every pattern page has a tiny example; the demos show realistic combinations with TinyBDD tests you can read like specs. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml new file mode 100644 index 0000000..2271782 --- /dev/null +++ b/docs/patterns/toc.yml @@ -0,0 +1,33 @@ +- name: Patterns + href: index.md + items: + - name: Behavioral + items: + - name: Chain + items: + - name: Behavioral.Chain.ActionChain + href: behavioral/chain/actionchain.md + - name: Behavioral.Chain.ResultChain + href: behavioral/chain/resultchain.md + - name: Strategy + items: + - name: Behavioral.Strategy.Strategy + href: behavioral/strategy/strategy.md + - name: Behavioral.Strategy.TryStrategy + href: behavioral/strategy/trystrategy.md + - name: Behavioral.Strategy.ActionStrategy + href: behavioral/strategy/actionstrategy.md + - name: Behavioral.Strategy.AsyncStrategy + href: behavioral/strategy/asyncstrategy.md + - name: Creational + items: + - name: Builder + items: + - name: Creational.Builder.BranchBuilder + href: creational/builder/branchbuilder.md + - name: Creational.Builder.ChainBuilder + href: creational/builder/chainbuilder.md + - name: Creational.Builder.Composer + href: creational/builder/composer.md + - name: Creational.Builder.MutableBuilder + href: creational/builder/mutablebuilder.md diff --git a/docs/toc.yml b/docs/toc.yml index db45c00..6d33da5 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -3,4 +3,12 @@ - name: API Reference href: api/ - homepage: api/toc.md + homepage: api/ + +- name: Patterns + href: patterns/ + homepage: patterns/ + +- name: Examples + href: examples/ + homepage: examples/ diff --git a/src/PatternKit.Core/Behavioral/Chain/ActionChain.cs b/src/PatternKit.Core/Behavioral/Chain/ActionChain.cs new file mode 100644 index 0000000..4a336e5 --- /dev/null +++ b/src/PatternKit.Core/Behavioral/Chain/ActionChain.cs @@ -0,0 +1,266 @@ +using System.Runtime.CompilerServices; + +namespace PatternKit.Behavioral.Chain; + +/// +/// A tiny, middleware-style pipeline for where each handler decides +/// to call next or short-circuit the chain. +/// +/// The context type threaded through the chain. +/// +/// +/// is built from a set of ordered handlers. Each handler receives the +/// current context and a next delegate. A handler can either: +/// +/// +/// Invoke next(in ctx) to continue the chain, or +/// Return without calling next to short-circuit the chain. +/// +/// +/// Registration order is preserved. A terminal handler can be provided; +/// it runs only if the chain was not short-circuited earlier (i.e., a previous step called next). +/// After the chain is immutable and thread-safe. +/// +/// +/// +/// +/// var log = new List<string>(); +/// +/// var chain = ActionChain<HttpRequest>.Create() +/// // Log request id but continue +/// .When((in r) => r.Headers.ContainsKey("X-Request-Id")) +/// .ThenContinue(r => log.Add($"reqid={r.Headers["X-Request-Id"]}")) +/// +/// // Deny missing auth for /admin/* and STOP the chain +/// .When((in r) => r.Path.StartsWith("/admin", StringComparison.Ordinal) && +/// !r.Headers.ContainsKey("Authorization")) +/// .ThenStop(r => log.Add("deny: missing auth")) +/// +/// // Tail: logs method/path only if earlier steps continued +/// .Finally((in r, next) => { log.Add($"{r.Method} {r.Path}"); next(in r); }) +/// .Build(); +/// +/// chain.Execute(new HttpRequest("GET", "/health", new Dictionary<string, string>())); +/// chain.Execute(new HttpRequest("GET", "/admin/metrics", new Dictionary<string, string>())); +/// // log: ["GET /health", "deny: missing auth", "GET /admin/metrics"] +/// +/// +public sealed class ActionChain +{ + /// + /// Delegate representing the continuation of the chain. + /// + /// The current context. + public delegate void Next(in TCtx ctx); + + /// + /// Delegate representing a chain handler. + /// + /// The current context. + /// + /// The continuation to invoke to proceed to the next handler. If a handler returns without + /// calling , the chain short-circuits. + /// + public delegate void Handler(in TCtx ctx, Next next); + + /// + /// Delegate representing a predicate over the context. + /// + /// The current context. + /// if the condition is met; otherwise . + public delegate bool Predicate(in TCtx ctx); + + private readonly Next _entry; + + private ActionChain(Next entry) => _entry = entry; + + /// + /// Executes the composed chain for the provided context. + /// + /// The context value passed through the chain. + /// + /// Execution starts at the first registered handler. Handlers may short-circuit by not calling next. + /// The terminal continuation is a no-op; calling it in the tail is optional. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Execute(in TCtx ctx) => _entry(in ctx); + + /// + /// Fluent builder for . + /// + /// + /// + /// Use to append middleware, to add conditional + /// blocks, and to set a terminal tail that runs only if the chain was + /// not short-circuited. Call to compose an immutable chain. + /// + /// Thread safety: The built chain is immutable and thread-safe; the builder is not. + /// + public sealed class Builder + { + private readonly List _handlers = new(8); + private Handler? _tail; + + /// + /// Adds a middleware handler to the end of the chain. + /// + /// The handler to append. + /// The same builder for chaining. + /// + /// The controls flow by deciding whether to call next. + /// + public Builder Use(Handler handler) + { + _handlers.Add(handler); + return this; + } + + /// + /// Starts a conditional block. If the predicate is false, the chain automatically continues. + /// + /// The condition to evaluate for the current context. + /// A that configures the conditional behavior. + /// + /// The generated handler ensures that when returns + /// , next is called automatically. + /// + public WhenBuilder When(Predicate predicate) => new(this, predicate); + + /// + /// Sets the terminal tail handler. It runs only if earlier handlers called next all the way through. + /// + /// The tail handler to run at the end of the chain. + /// The same builder for chaining. + /// + /// If an earlier handler returns without calling next, the tail does not run. + /// + public Builder Finally(Handler tail) + { + _tail = tail; + return this; + } + + /// + /// Composes all registered handlers (and optional tail) into a single immutable chain. + /// + /// An executable instance. + /// + /// Handlers are composed in reverse registration order so that execution preserves the original order. + /// A terminal no-op continuation is used; the tail may call next safely. + /// + public ActionChain Build() + { + // terminal "no-op" continuation + Next next = static (in _) => { }; + + if (_tail is not null) + { + var t = _tail; + var prev = next; + next = (in c) => t(in c, prev); + } + + for (var i = _handlers.Count - 1; i >= 0; i--) + { + var h = _handlers[i]; + var prev = next; + next = (in c) => h(in c, prev); + } + + return new ActionChain(next); + } + + /// + /// Builder for a conditional branch created via . + /// + public sealed class WhenBuilder + { + private readonly Builder _owner; + private readonly Predicate _pred; + + /// + /// Initializes a new . + /// + /// The parent builder. + /// The predicate to guard the conditional handler(s). + internal WhenBuilder(Builder owner, Predicate pred) => (_owner, _pred) = (owner, pred); + + /// + /// Adds a conditional handler: when the predicate is , run ; + /// otherwise automatically continue the chain. + /// + /// The handler to execute when the predicate is true. + /// The parent builder for chaining. + /// + /// The provided may itself call or omit next to continue or stop. + /// + public Builder Do(Handler handler) + { + var pred = _pred; // avoid capturing 'this' + _owner.Use((in c, next) => + { + if (pred(in c)) handler(in c, next); + else next(in c); + }); + return _owner; + } + + /// + /// Adds a conditional action that executes and stops the chain when the predicate is true. + /// + /// The action to perform before short-circuiting. + /// The parent builder for chaining. + /// + /// When the predicate is false, the chain continues automatically. + /// + public Builder ThenStop(Action action) + { + var pred = _pred; + _owner.Use((in c, next) => + { + if (pred(in c)) + { + action(c); + return; + } + + next(in c); + }); + return _owner; + } + + /// + /// Adds a conditional action that executes and then continues the chain when the predicate is true. + /// + /// The action to perform before continuing. + /// The parent builder for chaining. + /// + /// When the predicate is false, the chain continues automatically. + /// + public Builder ThenContinue(Action action) + { + var pred = _pred; + _owner.Use((in c, next) => + { + if (pred(in c)) action(c); + next(in c); + }); + return _owner; + } + } + } + + /// + /// Starts a new to configure an . + /// + /// A fresh builder instance. + /// + /// + /// var chain = ActionChain<MyCtx>.Create() + /// .Use((in c, next) => { /* pre */ next(in c); }) + /// .Finally((in c, next) => { /* tail */ next(in c); }) + /// .Build(); + /// + /// + public static Builder Create() => new(); +} \ No newline at end of file diff --git a/src/PatternKit.Core/Behavioral/Chain/ResultChain.cs b/src/PatternKit.Core/Behavioral/Chain/ResultChain.cs new file mode 100644 index 0000000..d38b4f3 --- /dev/null +++ b/src/PatternKit.Core/Behavioral/Chain/ResultChain.cs @@ -0,0 +1,249 @@ +using System.Runtime.CompilerServices; + +namespace PatternKit.Behavioral.Chain; + +/// +/// A first-match-wins chain that can produce a value. +/// Each handler receives (in TIn input, out TOut? result, Next next) and may either: +/// +/// Produce a value (set result and return ) to short-circuit, or +/// Delegate to next(input, out result) so later handlers can attempt to produce. +/// +/// +/// The input type threaded through the chain. +/// The potential output type produced by handlers. +/// +/// +/// Use when you want ordered rules that compute and return a value +/// (e.g., routing to an HttpResponse, choosing a price/promotion, parsing commands). +/// If you only need side effects, prefer . +/// +/// Execution semantics +/// +/// Handlers are evaluated in registration order. +/// The first handler that returns wins and the chain stops. +/// If no handler produces and no is configured, returns and result is . +/// If a Finally tail is set, it runs only if the chain reaches the tail (i.e., nobody produced earlier). It typically acts as a default/NotFound. +/// +/// +/// Performance: The chain composes to a single delegate at time; the built chain is immutable and thread-safe. +/// +/// +/// +/// A tiny router that returns a value: +/// .Create() +/// .When(static (in r) => r.Method == "GET" && r.Path == "/health") +/// .Then(r => new Response(200, "OK")) +/// .When(static (in r) => r.Method == "GET" && r.Path.StartsWith("/users/")) +/// .Then(r => new Response(200, $"user:{r.Path[7..]}")) +/// // default / not found +/// .Finally(static (in _, out Response? res, _) => { res = new(404, "not found"); return true; }) +/// .Build(); +/// +/// var ok1 = router.Execute(in new Request("GET", "/health"), out var res1); // true, 200 OK +/// var ok2 = router.Execute(in new Request("GET", "/nope"), out var res2); // true, 404 +/// ]]> +/// +public sealed class ResultChain +{ + /// + /// Delegate representing the continuation of the chain. + /// Implementations should return when a downstream handler produced a result. + /// + /// The current input value. + /// The produced result when any downstream handler succeeds. + /// if a result was produced; otherwise . + public delegate bool Next(in TIn input, out TOut? result); + + /// + /// Delegate representing a chain handler that may produce a value or delegate to . + /// + /// The current input value. + /// The result produced by this handler or by a downstream handler. + /// The continuation to invoke if this handler chooses not to (or cannot) produce. + /// + /// if this handler (or a downstream handler) produced a result; otherwise . + /// Returning short-circuits the chain. + /// + public delegate bool TryHandler(in TIn input, out TOut? result, Next next); + + /// + /// Delegate representing a predicate over the input. + /// + /// The current input value. + /// if the condition holds; otherwise . + public delegate bool Predicate(in TIn input); + + private readonly Next _entry; + + private ResultChain(Next entry) => _entry = entry; + + /// + /// Executes the composed chain. + /// + /// The input value to evaluate. + /// When the method returns , contains the produced result; otherwise . + /// if any handler (or the tail) produced a result; otherwise . + /// + /// The terminal continuation returns and leaves as . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Execute(in TIn input, out TOut? result) => _entry(in input, out result); + + /// + /// Fluent builder for . + /// + /// + /// Use to append raw handlers, to start a conditional section, + /// and to set a terminal fallback that runs only when no prior handler produced a result. + /// Call to compose an immutable, thread-safe chain. + /// + public sealed class Builder + { + private readonly List _handlers = new(8); + private TryHandler? _tail; + + /// + /// Appends a handler to the chain. + /// + /// The handler to add. It may produce a result or delegate to the provided next. + /// The same builder for chaining. + public Builder Use(TryHandler handler) + { + _handlers.Add(handler); + return this; + } + + /// + /// Starts a conditional block guarded by . + /// + /// The condition to evaluate for the current input. + /// A that configures behavior when the predicate is . + /// + /// When the predicate is , the generated handler automatically delegates to the next step. + /// + public WhenBuilder When(Predicate predicate) => new(this, predicate); + + /// + /// Sets a terminal fallback (e.g., default/NotFound) that runs only if the chain reaches the tail. + /// + /// The tail handler to execute at the end of the chain. + /// The same builder for chaining. + /// + /// If any earlier handler produced a result (returned ), the tail is not invoked. + /// + public Builder Finally(TryHandler tail) + { + _tail = tail; + return this; + } + + /// + /// Composes all registered handlers (and optional tail) into a single immutable chain. + /// + /// An executable instance. + /// + /// Handlers are folded from last to first so that runtime execution preserves registration order. + /// The terminal continuation returns and result. + /// + public ResultChain Build() + { + // terminal: no result + Next next = static (in _, out r) => + { + r = default; + return false; + }; + + if (_tail is not null) + { + var t = _tail; + var prev = next; + next = (in x, out r) => t(in x, out r, prev); + } + + for (var i = _handlers.Count - 1; i >= 0; i--) + { + var h = _handlers[i]; + var prev = next; + next = (in x, out r) => h(in x, out r, prev); + } + + return new ResultChain(next); + } + + /// + /// Builder for a conditional branch created via . + /// + public sealed class WhenBuilder + { + private readonly Builder _owner; + private readonly Predicate _pred; + + /// + /// Initializes a new . + /// + /// The parent builder. + /// The predicate guarding the conditional handler(s). + internal WhenBuilder(Builder owner, Predicate pred) => (_owner, _pred) = (owner, pred); + + /// + /// Adds a conditional handler that may produce a value or delegate to the next step. + /// When the predicate is , the chain automatically delegates. + /// + /// The conditional handler. + /// The parent builder for chaining. + public Builder Do(TryHandler handler) + { + var pred = _pred; + _owner.Use((in x, out r, next) + => pred(in x) + ? handler(in x, out r, next) + : next(in x, out r)); + return _owner; + } + + /// + /// Adds a conditional producer that returns a value and stops the chain when the predicate is . + /// + /// A function that maps the input to the produced result. + /// The parent builder for chaining. + /// + /// When the predicate is , the chain automatically delegates to the next step. + /// + public Builder Then(Func produce) + { + var pred = _pred; + _owner.Use((in x, out r, next) => + { + if (!pred(in x)) + return next(in x, out r); + + r = produce(x); + return true; + }); + return _owner; + } + } + } + + /// + /// Starts a new for configuring a . + /// + /// A fresh builder instance. + /// + /// .Create() + /// .When(static (in i) => i > 0).Then(i => $"+{i}") + /// .Finally(static (in _, out string? r, _) => { r = "default"; return true; }) + /// .Build(); + /// ]]> + /// + public static Builder Create() => new(); +} \ No newline at end of file diff --git a/src/PatternKit.Core/Behavioral/Strategy/ActionStrategy.cs b/src/PatternKit.Core/Behavioral/Strategy/ActionStrategy.cs new file mode 100644 index 0000000..56cea05 --- /dev/null +++ b/src/PatternKit.Core/Behavioral/Strategy/ActionStrategy.cs @@ -0,0 +1,186 @@ +using PatternKit.Common; +using PatternKit.Creational.Builder; + +namespace PatternKit.Behavioral.Strategy; + +/// +/// Represents a "first-match-wins" strategy pipeline built from predicate/action pairs, +/// where actions perform side effects and do not return a value. +/// +/// The input type accepted by each predicate and action. +/// +/// +/// is the action-only counterpart to +/// and . +/// It uses delegates to decide whether an +/// applies. The first predicate that returns determines which action is executed. +/// +/// +/// If no predicates match: +/// +/// +/// calls the configured default action if present; otherwise throws via . +/// returns if any action ran (including default); otherwise . +/// +/// Thread-safety: The built strategy is immutable and thread-safe. The is not thread-safe. +/// +/// +/// +/// var log = new List<string>(); +/// +/// void Log(string msg) => log.Add(msg); +/// +/// var s = ActionStrategy<int>.Create() +/// .When(static i => i > 0).Then(static i => Log($"+{i}")) +/// .When(static i => i < 0).Then(static i => Log($"-{i}")) +/// .Default(static _ => Log("zero")) +/// .Build(); +/// +/// s.Execute(5); // logs "+5" +/// s.Execute(-3); // logs "-3" +/// s.Execute(0); // logs "zero" +/// +/// +public sealed class ActionStrategy +{ + /// + /// Delegate representing a predicate used to test the input value. + /// + /// The input value. + /// if this predicate matches; otherwise . + public delegate bool Predicate(in TIn input); + + /// + /// Delegate representing an action that runs when its corresponding predicate matches. + /// + /// The input value. + public delegate void ActionHandler(in TIn input); + + private readonly Predicate[] _predicates; + private readonly ActionHandler[] _actions; + private readonly bool _hasDefault; + private readonly ActionHandler _default; + + private static ActionHandler Noop => static (in _) => { }; + + + private ActionStrategy(Predicate[] predicates, ActionHandler[] actions, bool hasDefault, ActionHandler @default) + => (_predicates, _actions, _hasDefault, _default) = (predicates, actions, hasDefault, @default); + + /// + /// Executes the first matching action for the given . + /// + /// The input value. + /// + /// Iterates predicates in registration order; runs the corresponding action for the first match and returns. + /// If no predicate matches and a default was configured via , + /// the default action runs. Otherwise, throws via . + /// + /// + /// Thrown when no predicates match and no default action is configured. + /// + public void Execute(in TIn input) + { + var predicates = _predicates; + for (var i = 0; i < predicates.Length; i++) + if (predicates[i](in input)) + { + _actions[i](in input); + return; + } + + if (_hasDefault) + { + _default(in input); + return; + } + + Throw.NoStrategyMatched(); + } + + /// + /// Attempts to execute the first matching action for the given . + /// + /// The input value. + /// + /// if an action (or default) executed; otherwise . + /// + /// + /// Unlike , this method never throws due to no matches. + /// + public bool TryExecute(in TIn input) + { + var predicates = _predicates; + for (var i = 0; i < predicates.Length; i++) + if (predicates[i](in input)) + { + _actions[i](in input); + return true; + } + + if (!_hasDefault) + return false; + + _default(in input); + return true; + + } + + /// + /// Provides a fluent API for constructing an . + /// + /// + /// Use to start a branch and to attach an action. + /// Optionally add a that runs when no predicates match. + /// Call to produce an immutable, thread-safe strategy. + /// + public sealed class Builder + { + private readonly BranchBuilder _core = BranchBuilder.Create(); + + public WhenBuilder When(Predicate predicate) => new(this, predicate); + + public Builder Default(ActionHandler action) + { + _core.Default(action); + return this; + } + + public ActionStrategy Build() + => _core.Build( + fallbackDefault: Noop, + projector: static (predicates, handlers, hasDefault, @default) + => new ActionStrategy(predicates, handlers, hasDefault, @default)); + + public sealed class WhenBuilder + { + private readonly Builder _owner; + private readonly Predicate _pred; + internal WhenBuilder(Builder owner, Predicate pred) => (_owner, _pred) = (owner, pred); + + public Builder Then(ActionHandler action) + { + _owner._core.Add(_pred, action); + return _owner; + } + } + } + + + /// + /// Creates a new for constructing an . + /// + /// A new instance. + /// + /// + /// var s = ActionStrategy<string>.Create() + /// .When(static s => string.IsNullOrEmpty(s)).Then(static _ => Console.WriteLine("empty")) + /// .Default(static _ => Console.WriteLine("other")) + /// .Build(); + /// + /// s.Execute(""); // prints "empty" + /// s.TryExecute("x"); // prints "other", returns true + /// + /// + public static Builder Create() => new(); +} \ No newline at end of file diff --git a/src/PatternKit.Core/Behavioral/Strategy/AsyncStrategy.cs b/src/PatternKit.Core/Behavioral/Strategy/AsyncStrategy.cs new file mode 100644 index 0000000..54fb317 --- /dev/null +++ b/src/PatternKit.Core/Behavioral/Strategy/AsyncStrategy.cs @@ -0,0 +1,217 @@ +using PatternKit.Common; +using PatternKit.Creational.Builder; + +namespace PatternKit.Behavioral.Strategy; + +/// +/// Composable, asynchronous strategy that selects the first matching branch +/// (predicate + handler) and executes its handler. +/// +/// The input type supplied to predicates and handlers. +/// The result type returned by handlers. +/// +/// +/// This strategy evaluates predicates in the order they were added. The first +/// predicate that returns determines the chosen handler. +/// If no predicates match, an optional handler +/// is invoked. Without a default, +/// throws to signal that no branch matched. +/// +/// +/// Instances built via are immutable and thread-safe +/// for concurrent execution, assuming supplied predicates/handlers are thread-safe. +/// +/// +/// +/// Basic usage: +/// +/// var strat = AsyncStrategy<int, string>.Create() +/// .When((n, ct) => new ValueTask<bool>(n < 0)) +/// .Then((n, ct) => new ValueTask<string>("negative")) +/// .When((n, ct) => new ValueTask<bool>(n == 0)) +/// .Then((n, ct) => new ValueTask<string>("zero")) +/// .Default((n, ct) => new ValueTask<string>("positive")) +/// .Build(); +/// +/// var result = await strat.ExecuteAsync(5, CancellationToken.None); // "positive" +/// +/// +/// +public sealed class AsyncStrategy +{ + /// + /// Asynchronous predicate delegate that decides whether a branch can handle the input. + /// + /// The input value to evaluate. + /// A cancellation token. + /// + /// A producing if the branch + /// should handle the input; otherwise . + /// + public delegate ValueTask Predicate(TIn input, CancellationToken ct); + + /// + /// Asynchronous handler delegate that produces the result for a matching branch. + /// + /// The input value to handle. + /// A cancellation token. + /// + /// A producing the result for the branch. + /// + public delegate ValueTask Handler(TIn input, CancellationToken ct); + + private readonly Predicate[] _predicates; + private readonly Handler[] _handlers; + private readonly bool _hasDefault; + private readonly Handler _default; + + // Internal fallback used by the builder when a default is not provided. + private static Handler DefaultResult => + (_, _) => new ValueTask(default(TOut)!); + + private AsyncStrategy(Predicate[] preds, Handler[] handlers, bool hasDefault, Handler def) => + (_predicates, _handlers, _hasDefault, _default) = (preds, handlers, hasDefault, def); + + /// + /// Executes the strategy by evaluating predicates in order and invoking the first matching handler. + /// + /// The input value passed to predicates and handlers. + /// An optional cancellation token. + /// + /// A that completes with the result of the chosen handler. + /// + /// + /// If no predicates match and a default handler was configured, the default handler is invoked. + /// Otherwise the method throws to indicate that no branch matched. + /// + /// + /// Thrown when no predicate matches and no default handler is configured. + /// + public async ValueTask ExecuteAsync(TIn input, CancellationToken ct = default) + { + var preds = _predicates; + for (var i = 0; i < preds.Length; i++) + if (await preds[i](input, ct).ConfigureAwait(false)) + return await _handlers[i](input, ct).ConfigureAwait(false); + + if (_hasDefault) + return await _default(input, ct).ConfigureAwait(false); + + return Throw.NoStrategyMatched(); + } + + /// + /// Fluent builder for composing branches. + /// + /// + /// Use to add predicate/handler branches and + /// to set an optional fallback handler. Supports + /// synchronous adapters for convenience—see , + /// , + /// and . + /// + public sealed class Builder + { + private readonly BranchBuilder _core = BranchBuilder.Create(); + + /// + /// Adds a new branch that will be considered during execution. + /// + /// An asynchronous predicate for the branch. + /// + /// A that allows specifying the corresponding handler + /// via . + /// + /// Thrown when is . + public WhenBuilder When(Predicate pred) => new(this, pred); + + /// + /// Sets the default (fallback) handler used when no predicates match. + /// + /// The asynchronous default handler. + /// The current for chaining. + /// Thrown when is . + public Builder Default(Handler handler) + { + _core.Default(handler); + return this; + } + + /// + /// Finalizes the configuration and creates an immutable . + /// + /// An immutable strategy instance. + public AsyncStrategy Build() => + _core.Build( + fallbackDefault: DefaultResult, + projector: static (p, h, hasDef, def) => new AsyncStrategy(p, h, hasDef, def)); + + /// + /// Intermediate builder returned by allowing a corresponding handler to be set. + /// + public sealed class WhenBuilder + { + private readonly Builder _owner; + private readonly Predicate _pred; + + internal WhenBuilder(Builder owner, Predicate pred) + { + _owner = owner; + _pred = pred; + } + + /// + /// Assigns the handler to execute when the associated predicate evaluates to . + /// + /// The asynchronous handler for this branch. + /// The parent for further configuration. + /// Thrown when is . + public Builder Then(Handler handler) + { + _owner._core.Add(_pred, handler); + return _owner; + } + } + + // -------- Synchronous adapters -------- + + /// + /// Adds a branch using a synchronous predicate (no ). + /// + /// Synchronous predicate; wrapped as an async predicate. + /// A to specify the handler. + public WhenBuilder When(Func syncPred) + { + return When(Adapter); + ValueTask Adapter(TIn x, CancellationToken _) => new ValueTask(syncPred(x)); + } + + /// + /// Adds a branch using a synchronous predicate that accepts a . + /// + /// Synchronous predicate receiving a token; wrapped as async. + /// A to specify the handler. + public WhenBuilder When(Func syncPredWithCt) + { + return When(Adapter); + ValueTask Adapter(TIn x, CancellationToken ct) => new ValueTask(syncPredWithCt(x, ct)); + } + + /// + /// Sets the default (fallback) handler using a synchronous delegate. + /// + /// Synchronous default handler; wrapped as async. + /// The current for chaining. + public Builder Default(Func syncHandler) + { + return Default(Adapter); + ValueTask Adapter(TIn x, CancellationToken _) => new ValueTask(syncHandler(x)); + } + } + + /// + /// Creates a new for configuring an . + /// + /// A new instance. + public static Builder Create() => new(); +} \ No newline at end of file diff --git a/PatternKit.Core/Behavioral/Strategy/Strategy.Try.cs b/src/PatternKit.Core/Behavioral/Strategy/Strategy.Try.cs similarity index 59% rename from PatternKit.Core/Behavioral/Strategy/Strategy.Try.cs rename to src/PatternKit.Core/Behavioral/Strategy/Strategy.Try.cs index e7719ba..2297ede 100644 --- a/PatternKit.Core/Behavioral/Strategy/Strategy.Try.cs +++ b/src/PatternKit.Core/Behavioral/Strategy/Strategy.Try.cs @@ -1,3 +1,5 @@ +using PatternKit.Creational.Builder; + namespace PatternKit.Behavioral.Strategy; /// @@ -87,97 +89,49 @@ public bool Execute(in TIn input, out TOut? result) /// public sealed class Builder { - private readonly List _handlers = new(8); - - /// - /// Adds a handler that is always included in the pipeline. - /// - /// The handler delegate to add. - /// The current instance for chaining. - /// Handlers are evaluated in the order they are added. + private readonly ChainBuilder _core = ChainBuilder.Create(); + public Builder Always(TryHandler handler) { - _handlers.Add(handler); + _core.Add(handler); return this; } - /// - /// Starts a conditional block where handlers are added only if the evaluates to . - /// - /// - /// A function evaluated once during build time to determine whether - /// handlers inside the block should be added. - /// - /// A for adding conditional handlers. public WhenBuilder When(Func condition) => new(this, condition()); - /// - /// Adds a handler to the end of the pipeline, typically as a fallback. - /// - /// The handler to append. - /// The current instance for chaining. public Builder Finally(TryHandler handler) { - _handlers.Add(handler); + _core.Add(handler); return this; } - /// - /// Provides syntactic sugar for chaining fluent calls. - /// public Builder Or => this; - /// - /// Builds the immutable from the collected handlers. - /// - /// A compiled ready for execution. - public TryStrategy Build() => new(_handlers.ToArray()); + public TryStrategy Build() + => _core.Build(static hs => new TryStrategy(hs)); - /// - /// Represents a conditional builder context for adding handlers when a - /// condition is . - /// public readonly struct WhenBuilder { private readonly Builder _owner; private readonly bool _cond; - internal WhenBuilder(Builder owner, bool cond) => (_owner, _cond) = (owner, cond); + internal WhenBuilder(Builder owner, bool cond) + { + _owner = owner; + _cond = cond; + } - /// - /// Adds a handler to the conditional block. - /// - /// The handler to add. - /// The current instance for further chaining. - /// Ignored if the condition supplied to was . public WhenBuilder Add(TryHandler handler) { - if (_cond) _owner._handlers.Add(handler); + _owner._core.AddIf(_cond, handler); return this; } - /// - /// Adds an additional handler in the same conditional block. - /// - /// The handler to add. - /// The current instance for further chaining. public WhenBuilder And(TryHandler handler) => Add(handler); - /// - /// Returns control back to the parent to continue chaining. - /// public Builder End => _owner; - - /// - /// Alias for for more natural chaining. - /// public Builder Or => _owner; - /// - /// Adds a final (fallback) handler and returns the parent . - /// - /// The handler to add. - /// The parent . public Builder Finally(TryHandler handler) => _owner.Finally(handler); } } diff --git a/PatternKit.Core/Behavioral/Strategy/Strategy.cs b/src/PatternKit.Core/Behavioral/Strategy/Strategy.cs similarity index 64% rename from PatternKit.Core/Behavioral/Strategy/Strategy.cs rename to src/PatternKit.Core/Behavioral/Strategy/Strategy.cs index 4540687..6bd285a 100644 --- a/PatternKit.Core/Behavioral/Strategy/Strategy.cs +++ b/src/PatternKit.Core/Behavioral/Strategy/Strategy.cs @@ -1,4 +1,5 @@ using PatternKit.Common; +using PatternKit.Creational.Builder; namespace PatternKit.Behavioral.Strategy; @@ -52,6 +53,8 @@ public sealed class Strategy private readonly bool _hasDefault; private readonly Handler _default; + private static Handler DefaultResult => static (in _) => default!; + private Strategy(Predicate[] predicates, Handler[] handlers, bool hasDefault, Handler @default) => (_predicates, _handlers, _hasDefault, _default) = (predicates, handlers, hasDefault, @default); @@ -102,86 +105,31 @@ public TOut Execute(in TIn input) /// public sealed class Builder { - private readonly List _preds = new(8); - private readonly List _handlers = new(8); - private Handler? _default; + private readonly BranchBuilder _core = BranchBuilder.Create(); - /// - /// Starts a new conditional branch by specifying a . - /// - /// The condition to test against the input. - /// - /// A that allows chaining a call - /// to associate a handler with this predicate. - /// - /// - /// Each call to represents a separate branch in the strategy. - /// public WhenBuilder When(Predicate predicate) => new(this, predicate); - /// - /// Sets a default handler to use when no predicates match. - /// - /// The handler to invoke when no branch matches. - /// The current instance for chaining. public Builder Default(Handler handler) { - _default = handler; + _core.Default(handler); return this; } - /// - /// Builds an immutable from the configured branches. - /// - /// - /// A containing all added predicate/handler pairs and - /// an optional default handler. - /// - /// - /// The returned is thread-safe and can be reused across calls. - /// public Strategy Build() - { - var predicates = _preds.ToArray(); - var handlers = _handlers.ToArray(); - var defaultHandler = _default ?? (static TOut (in TIn _) => default!); + => _core.Build( + fallbackDefault: DefaultResult, + projector: static (predicates, handlers, hasDefault, @default) + => new Strategy(predicates, handlers, hasDefault, @default)); - return new Strategy( - predicates, - handlers, - _default is not null, - defaultHandler); - } - - /// - /// Builder context returned from that allows pairing - /// a predicate with a handler. - /// public sealed class WhenBuilder { private readonly Builder _owner; private readonly Predicate _pred; + internal WhenBuilder(Builder owner, Predicate pred) => (_owner, _pred) = (owner, pred); - internal WhenBuilder(Builder owner, Predicate pred) - => (_owner, _pred) = (owner, pred); - - /// - /// Associates the current predicate with a and returns the parent builder. - /// - /// The handler to execute when the predicate matches. - /// The parent instance for chaining. - /// - /// - /// var strategy = Strategy<int, string>.Create() - /// .When(i => i > 0).Then(i => "positive") - /// .When(i => i < 0).Then(i => "negative") - /// .Build(); - /// - /// public Builder Then(Handler handler) { - _owner._preds.Add(_pred); - _owner._handlers.Add(handler); + _owner._core.Add(_pred, handler); return _owner; } } diff --git a/PatternKit.Core/Common/Functional.cs b/src/PatternKit.Core/Common/Functional.cs similarity index 98% rename from PatternKit.Core/Common/Functional.cs rename to src/PatternKit.Core/Common/Functional.cs index 6998a93..fff4226 100644 --- a/PatternKit.Core/Common/Functional.cs +++ b/src/PatternKit.Core/Common/Functional.cs @@ -13,7 +13,7 @@ namespace PatternKit.Common; /// /// to create a present value. /// to represent absence. -/// / / for use. +/// / / for use. /// /// It is immutable and does not box primitives. /// diff --git a/PatternKit.Core/Common/Throw.cs b/src/PatternKit.Core/Common/Throw.cs similarity index 98% rename from PatternKit.Core/Common/Throw.cs rename to src/PatternKit.Core/Common/Throw.cs index ed3ae79..bbb421c 100644 --- a/PatternKit.Core/Common/Throw.cs +++ b/src/PatternKit.Core/Common/Throw.cs @@ -31,7 +31,7 @@ namespace PatternKit.Common; /// } /// /// -static class Throw +public static class Throw { /// /// Throws an indicating that no strategy branch matched @@ -43,7 +43,7 @@ static class Throw /// be used in expression contexts requiring a value. /// /// - /// Always thrown with the message "No strategy matched and no default provided.". + /// Always thrown with the message "No strategy matched and no default provided." /// /// /// diff --git a/src/PatternKit.Core/Creational/Builder/BranchBuilder.cs b/src/PatternKit.Core/Creational/Builder/BranchBuilder.cs new file mode 100644 index 0000000..5dfe6f5 --- /dev/null +++ b/src/PatternKit.Core/Creational/Builder/BranchBuilder.cs @@ -0,0 +1,55 @@ +namespace PatternKit.Creational.Builder; + +/// +/// Reusable, allocation-light, fluent builder for collecting predicate/handler pairs + optional default, +/// and projecting them into a concrete product. +/// +/// Delegate type of the predicate (e.g., bool Predicate(in TIn)). +/// Delegate type of the handler (e.g., TOut Handler(in TIn) or void Action(in TIn)). +public sealed class BranchBuilder +{ + private readonly List _predicates = new(8); + private readonly List _handlers = new(8); + private THandler? _default; + + private BranchBuilder() + { + } + + public static BranchBuilder Create() => new(); + + /// Adds a predicate/handler pair. + public BranchBuilder Add(TPred predicate, THandler handler) + { + _predicates.Add(predicate); + _handlers.Add(handler); + return this; + } + + /// Sets (or replaces) the default handler. + public BranchBuilder Default(THandler handler) + { + _default = handler; + return this; + } + + /// + /// Builds a product using the collected pairs and default. If no default was set, + /// is supplied and flagged as not user-configured. + /// + /// The product type to construct. + /// Default handler to use when none explicitly configured. + /// + /// (predicates, handlers, hasDefault, @default) → TProduct + /// + public TProduct Build( + THandler fallbackDefault, + Func projector) + { + var predicates = _predicates.ToArray(); + var handlers = _handlers.ToArray(); + var hasDefault = _default is not null; + var def = _default ?? fallbackDefault; + return projector(predicates, handlers, hasDefault, def); + } +} \ No newline at end of file diff --git a/src/PatternKit.Core/Creational/Builder/ChainBuilder.cs b/src/PatternKit.Core/Creational/Builder/ChainBuilder.cs new file mode 100644 index 0000000..bbe8a07 --- /dev/null +++ b/src/PatternKit.Core/Creational/Builder/ChainBuilder.cs @@ -0,0 +1,73 @@ +namespace PatternKit.Creational.Builder; + +/// +/// Lightweight, allocation-friendly builder that collects items in registration order and +/// projects them into a concrete product. +/// +/// The element type stored by the builder. +/// +/// +/// Design goals: minimal overhead, predictable iteration order, and a small fluent API +/// for collecting items to later transform via . +/// +/// +/// Instances are mutable until consumed by callers; builders are not thread-safe. +/// +/// +public sealed class ChainBuilder +{ + private readonly List _items = new(8); + + private ChainBuilder() + { + } + + /// + /// Creates a new . + /// + /// A fresh instance. + public static ChainBuilder Create() => new(); + + /// + /// Appends to the builder in registration order. + /// + /// The item to append. + /// The same instance for fluent chaining. + public ChainBuilder Add(T item) + { + _items.Add(item); + return this; + } + + /// + /// Conditionally appends when is . + /// + /// When the is added. + /// The item to append when the condition holds. + /// The same instance for fluent chaining. + public ChainBuilder AddIf(bool condition, T item) + { + if (condition) _items.Add(item); + return this; + } + + /// + /// Projects the collected items into a product using the provided . + /// + /// The resulting product type. + /// + /// Function that receives a snapshot array of the collected items and produces the desired product. + /// The builder provides a defensive copy via ToArray() to avoid exposing internal storage. + /// + /// The projected product returned by . + /// + /// + /// var csv = ChainBuilder{string}.Create() + /// .Add("A") + /// .Add("B") + /// .Build(arr => string.Join(",", arr)); + /// + /// + public TProduct Build(Func projector) + => projector(_items.ToArray()); +} \ No newline at end of file diff --git a/src/PatternKit.Core/Creational/Builder/Composer.cs b/src/PatternKit.Core/Creational/Builder/Composer.cs new file mode 100644 index 0000000..0692f7a --- /dev/null +++ b/src/PatternKit.Core/Creational/Builder/Composer.cs @@ -0,0 +1,99 @@ +namespace PatternKit.Creational.Builder; + +/// +/// A fluent, explicit composer that accumulates state in (often a small struct) +/// via pure transformations and finally projects it to . +/// +/// The internal builder state (prefer a small struct for performance). +/// The final output type. +/// +/// Use when your output is immutable: build up state with +/// and finalize with . +/// +/// +/// +/// public readonly record struct PersonState(string? Name, int Age); +/// public sealed record PersonDto(string Name, int Age); +/// +/// var dto = Composer<PersonState, PersonDto> +/// .New(static () => default) +/// .With(static s => s with { Name = "Ada" }) +/// .With(static s => s with { Age = 30 }) +/// .Require(static s => string.IsNullOrWhiteSpace(s.Name) ? "Name is required." : null) +/// .Build(static s => new PersonDto(s.Name!, s.Age)); +/// +/// +public sealed class Composer +{ + private readonly Func _seed; + private Func? _pipeline; + private Func? _validators; + + private Composer(Func seed) => _seed = seed; + + /// + /// Creates a new with the specified seed factory. + /// + /// A factory for the initial state (e.g., static () => default). + /// A new . + public static Composer New(Func seed) => new(seed); + + /// + /// Adds a pure state transformation; prefer static lambdas to avoid captures. + /// + /// A function that transforms the current state. + /// The current for fluent chaining. + /// + /// Transformations are composed in the order they are added and applied once during . + /// + public Composer With(Func transform) + { + _pipeline = _pipeline is null ? transform : Chain(_pipeline, transform); + return this; + + static Func Chain(Func a, Func b) + => s => b(a(s)); + } + + /// + /// Adds a validation rule; return for success or a non-empty error message for failure. + /// + /// A function that validates the composed state. + /// The current for fluent chaining. + /// + /// Validations are evaluated once during . + /// The first failure throws an . + /// + public Composer Require(Func validate) + { + _validators = _validators is null ? validate : Chain(_validators, validate); + return this; + + static Func Chain(Func a, Func b) + => s => a(s) ?? b(s); + } + + /// + /// Builds the final output using , after applying transformations and validations. + /// + /// A projection that converts the final state into the output value. + /// The built value. + /// Thrown if any validator returns a non-null, non-empty message. + /// + /// Execution order: + /// + /// Compute the final state by applying the composed pipeline to the seed. + /// Evaluate all validations; throw on first failure. + /// Invoke the projection to obtain the output. + /// + /// + public TOut Build(Func project) + { + var state = _pipeline is null ? _seed() : _pipeline(_seed()); + + var error = _validators?.Invoke(state); + return error is { Length: > 0 } + ? throw new InvalidOperationException(error) + : project(state); + } +} \ No newline at end of file diff --git a/src/PatternKit.Core/Creational/Builder/MutableBuilder.cs b/src/PatternKit.Core/Creational/Builder/MutableBuilder.cs new file mode 100644 index 0000000..28d54e7 --- /dev/null +++ b/src/PatternKit.Core/Creational/Builder/MutableBuilder.cs @@ -0,0 +1,240 @@ +using System.Runtime.CompilerServices; + +namespace PatternKit.Creational.Builder; + +/// +/// A minimal, explicit, fluent builder that creates a new using a factory, +/// applies zero or more mutations, validates, and returns the instance. +/// +/// The object type being built. +/// +/// +/// Design goals: explicit, allocation-light, reflection-free. Prefer static lambdas for +/// to avoid closures. Validations are evaluated in build order, +/// and the first failure message throws an . +/// +/// Thread-safety: instances of are not thread-safe. +/// +/// +/// +/// public sealed class Person +/// { +/// public string? Name { get; set; } +/// public int Age { get; set; } +/// } +/// +/// var person = MutableBuilder<Person>.New(static () => new Person()) +/// .With(static p => p.Name = "Ada") +/// .With(static p => p.Age = 30) +/// .Require(x => string.IsNullOrWhiteSpace(x.Name) ? "Name is required." : null) +/// .Build(); +/// +/// +public sealed class MutableBuilder +{ + private readonly Func _factory; + + // store validators without closures + private readonly List> _validators = new(4); + private Action? _mutations; + + /// + /// Initializes a new builder with the provided . + /// + /// A function that creates a fresh instance of . + public MutableBuilder(Func factory) => _factory = factory; + + /// + /// Creates a new that uses the specified to instantiate objects. + /// + /// A function that creates a fresh instance of . + /// A new instance. + /// + /// + /// var builder = MutableBuilder<MyOptions>.New(static () => new MyOptions()); + /// + /// + public static MutableBuilder New(Func factory) => new(factory); + + /// + /// Appends a mutation that will be applied to the instance before validation. + /// + /// A side-effect action that mutates the instance. + /// The current for fluent chaining. + /// + /// Prefer static lambdas (e.g., static x => x.Prop = ...) to avoid capturing outer state. + /// Multiple calls are combined and invoked in the order they were added. + /// + /// + /// + /// builder.With(static x => x.Timeout = TimeSpan.FromSeconds(5)) + /// .With(static x => x.Enabled = true); + /// + /// + public MutableBuilder With(Action mutate) + { + _mutations = _mutations is null ? mutate : (Action)Delegate.Combine(_mutations, mutate); + return this; + } + + /// + /// Adds a validation rule; return when valid or a non-empty error message when invalid. + /// + /// A function that validates the built instance. + /// The current for fluent chaining. + /// + /// Validations are evaluated after all mutations during . The first non-null/ non-empty + /// message triggers an . + /// + public MutableBuilder Require(Func validate) + { + _validators.Add(new FuncValidator(validate)); + return this; + } + + /// + /// Adds a stateful validation rule without capturing. Passes to the validator. + /// + /// The type of the additional state required for validation. + /// Arbitrary state passed to . + /// + /// A function that validates using the built value and ; return + /// when valid or a non-empty error message when invalid. + /// + /// The current for fluent chaining. + /// + /// This overload enables use of static lambdas to avoid closures: + /// + /// builder.Require((min, max), static (x, s) => x.Value is >= s.min and <= s.max ? null : "Out of range"); + /// + /// + public MutableBuilder Require(TState state, Func validate) + { + _validators.Add(new StatefulValidator(state, validate)); + return this; + } + + /// + /// Builds the object, applying mutations and then validations. + /// + /// The fully built instance. + /// + /// Thrown when any validation returns a non-null, non-empty error message. + /// + /// + /// Execution order: + /// + /// Invoke factory to create the object. + /// Apply all registered mutations in order. + /// Evaluate validations in order; throw on first failure. + /// + /// + public T Build() + { + var obj = _factory(); + _mutations?.Invoke(obj); + + foreach (var v in _validators) + { + var err = v.Validate(obj); + if (!string.IsNullOrEmpty(err)) + throw new InvalidOperationException(err); + } + + return obj; + } + + // --- validator plumbing (internal) --- + /// + /// (Internal) Abstraction for a validation rule. Not intended for public consumption. + /// + private interface IValidator + { + /// Validates ; return if valid. + string? Validate(TValue value); + } + + private sealed class FuncValidator : IValidator + { + private readonly Func _f; + public FuncValidator(Func f) => _f = f; + public string? Validate(T value) => _f(value); + } + + private sealed class StatefulValidator : IValidator + { + private readonly TState _state; + private readonly Func _f; + public StatefulValidator(TState state, Func f) => (_state, _f) = (state, f); + public string? Validate(T value) => _f(value, _state); + } +} + +/// +/// Helper extensions for common builder/validation patterns. +/// +/// +/// These helpers prefer static lambdas and use the stateful validation overloads to avoid closures. +/// +public static class BuilderExtensions +{ + /// + /// Adds a non-empty string requirement for a selected property. + /// + /// The builder's target type. + /// The builder. + /// Selects the string to validate. + /// The parameter or property name to report in the error message. + /// The same for fluent chaining. + /// + /// + /// var result = MutableBuilder<Person>.New(static () => new Person()) + /// .With(static p => p.Name = "") + /// .RequireNotEmpty(static p => p.Name, nameof(Person.Name)) + /// .Build(); // throws InvalidOperationException + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static MutableBuilder RequireNotEmpty( + this MutableBuilder b, + Func selector, + string paramName) + => b.Require((selector, paramName), static (x, s) => + { + var v = s.selector(x); + return string.IsNullOrWhiteSpace(v) ? $"{s.paramName} must be non-empty." : null; + }); + + /// + /// Adds an inclusive integer range requirement for a selected property. + /// + /// The builder's target type. + /// The builder. + /// Selects the integer value to validate. + /// The minimum allowed value (inclusive). + /// The maximum allowed value (inclusive). + /// The parameter or property name to report in the error message. + /// The same for fluent chaining. + /// + /// + /// var result = MutableBuilder<Person>.New(static () => new Person()) + /// .With(static p => p.Age = -1) + /// .RequireRange(static p => p.Age, 0, 130, nameof(Person.Age)) + /// .Build(); // throws InvalidOperationException + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static MutableBuilder RequireRange( + this MutableBuilder b, + Func selector, + int minInclusive, + int maxInclusive, + string paramName) + => b.Require((selector, minInclusive, maxInclusive, paramName), static (x, s) => + { + var v = s.selector(x); + return (v < s.minInclusive || v > s.maxInclusive) + ? $"{s.paramName} must be within [{s.minInclusive}, {s.maxInclusive}] but was {v}." + : null; + }); +} \ No newline at end of file diff --git a/src/PatternKit.Core/PatternKit.Core.csproj b/src/PatternKit.Core/PatternKit.Core.csproj new file mode 100644 index 0000000..0df6d86 --- /dev/null +++ b/src/PatternKit.Core/PatternKit.Core.csproj @@ -0,0 +1,12 @@ + + + + PatternKit + + + + + + + + diff --git a/src/PatternKit.Core/Properties/AssemblyCoverage.cs b/src/PatternKit.Core/Properties/AssemblyCoverage.cs new file mode 100644 index 0000000..5921f40 --- /dev/null +++ b/src/PatternKit.Core/Properties/AssemblyCoverage.cs @@ -0,0 +1,5 @@ +#if NETSTANDARD2_1 +// Exclude the entire assembly from coverage when built for netstandard2.1 +[assembly: System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +#endif + diff --git a/src/PatternKit.Core/Shims/DoesNotReturnAttribute.cs b/src/PatternKit.Core/Shims/DoesNotReturnAttribute.cs new file mode 100644 index 0000000..ab07cab --- /dev/null +++ b/src/PatternKit.Core/Shims/DoesNotReturnAttribute.cs @@ -0,0 +1,5 @@ +#if NETSTANDARD2_0 +namespace System.Diagnostics.CodeAnalysis; + +public class DoesNotReturnAttribute : Attribute { } +#endif \ No newline at end of file diff --git a/src/PatternKit.Core/packages.lock.json b/src/PatternKit.Core/packages.lock.json new file mode 100644 index 0000000..8a177e7 --- /dev/null +++ b/src/PatternKit.Core/packages.lock.json @@ -0,0 +1,38 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.0": { + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Direct", + "requested": "[4.5.4, )", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "4.5.3", + "contentHash": "3TIsJhD1EiiT0w2CcDMN/iSSwnNnsrnbzeVHSKkaEgV85txMprmuO+Yq2AdSbeVGcg28pdNDTPK87tJhX7VFHw==" + } + }, + ".NETStandard,Version=v2.1": {}, + "net8.0": {}, + "net9.0": {} + } +} \ No newline at end of file diff --git a/src/PatternKit.Examples/ApiGateway/Demo.cs b/src/PatternKit.Examples/ApiGateway/Demo.cs new file mode 100644 index 0000000..a9eafcc --- /dev/null +++ b/src/PatternKit.Examples/ApiGateway/Demo.cs @@ -0,0 +1,60 @@ +namespace PatternKit.Examples.ApiGateway; + +public static class Demo +{ + public static void Run() + { + var router = MiniRouter.Create() + // --- middleware (first-match-wins) --- + // capture request-id when present + .Use( + static (in r) => r.Headers.ContainsKey("X-Request-Id"), + static (in r) => Console.WriteLine($"reqid={r.Headers["X-Request-Id"]}")) + // auth short-circuit: /admin requires bearer token + .Use( + static (in r) => r.Path.StartsWith("/admin", StringComparison.Ordinal) && + !r.Headers.ContainsKey("Authorization"), + static (in _) => Console.WriteLine("Denied: missing Authorization")) + // default is noop (set in Build) + + // --- routes (first-match-wins) --- + .Map( + static (in r) => r is { Method: "GET", Path: "/health" }, + static (in _) => Responses.Text(200, "OK")) + .Map( + static (in r) => r.Method == "GET" && r.Path.StartsWith("/users/", StringComparison.Ordinal), + static (in r) => + { + var idStr = r.Path["/users/".Length..]; + return int.TryParse(idStr, out var id) + ? Responses.Json(200, $"{{\"id\":{id},\"name\":\"user{id}\"}}") + : Responses.Text(404, "User not found"); + }) + .Map( + static (in r) => r is { Method: "POST", Path: "/users" }, + + // pretend to create the user from r.Body... + static (in _) => Responses.Json(201, "{\"ok\":true}")) + .Map( + static (in r) => r.Path.StartsWith("/admin", StringComparison.Ordinal) && + !r.Headers.ContainsKey("Authorization"), + static (in _) => Responses.Unauthorized()) + .NotFound(static (in _) => Responses.NotFound()) + .Build(); + + // --- simulate a few calls --- + var commonHeaders = new Dictionary { ["Accept"] = "application/json" }; + + Print(router.Handle(new Request("GET", "/health", commonHeaders))); + Print(router.Handle(new Request("GET", "/users/42", commonHeaders))); + Print(router.Handle(new Request("GET", "/users/abc", commonHeaders))); + Print(router.Handle(new Request("GET", "/admin/metrics", new Dictionary()))); // unauthorized + Print(router.Handle(new Request("POST", "/users", commonHeaders, "{\"name\":\"Ada\"}"))); + Print(router.Handle(new Request("GET", "/nope", commonHeaders))); + + return; + + static void Print(Response res) + => Console.WriteLine($"{res.StatusCode} {res.ContentType}\n{res.Body}\n"); + } +} \ No newline at end of file diff --git a/src/PatternKit.Examples/ApiGateway/MiniRouter.cs b/src/PatternKit.Examples/ApiGateway/MiniRouter.cs new file mode 100644 index 0000000..b2f8560 --- /dev/null +++ b/src/PatternKit.Examples/ApiGateway/MiniRouter.cs @@ -0,0 +1,152 @@ +using PatternKit.Behavioral.Strategy; + +namespace PatternKit.Examples.ApiGateway; + +// Ultra-minimal request/response types used by the demo. +public readonly record struct Request( + string Method, + string Path, + IReadOnlyDictionary Headers, + string? Body = null +); + +public readonly record struct Response( + int StatusCode, + string ContentType, + string Body +); + +public static class Responses +{ + public static Response Text(int status, string body) + => new(status, "text/plain; charset=utf-8", body); + + public static Response Json(int status, string json) + => new(status, "application/json; charset=utf-8", json); + + public static Response NotFound() + => Text(404, "Not Found"); + + public static Response Unauthorized() + => Text(401, "Unauthorized"); +} + +/// +/// A tiny API gateway/router showing how ActionStrategy + Strategy + TryStrategy +/// compose into a pragmatic HTTP-ish pipeline. +/// +public sealed class MiniRouter +{ + private readonly ActionStrategy _middleware; + private readonly Strategy _routes; + private readonly TryStrategy _negotiate; // produces a content-type + + private MiniRouter( + ActionStrategy middleware, + Strategy routes, + TryStrategy negotiate) + => (_middleware, _routes, _negotiate) = (middleware, routes, negotiate); + + public Response Handle(in Request req) + { + // fire first-matching side-effect (e.g., logging, auth short-circuit) + _middleware.TryExecute(in req); + + var res = _routes.Execute(in req); + + // simple content negotiation: pick content-type if handler left it blank + if (string.IsNullOrWhiteSpace(res.ContentType) + && _negotiate.Execute(in req, out var ct) + && ct is { Length: > 0 }) + return res with { ContentType = ct }; + + return res; + } + + public static Builder Create() => new(); + + public sealed class Builder + { + private readonly ActionStrategy.Builder _mw = ActionStrategy.Create(); + private readonly Strategy.Builder _routes = Strategy.Create(); + private TryStrategy? _neg; + + /// Add first-match middleware (e.g. logging, metrics, CORS/OPTIONS, auth). + public Builder Use(ActionStrategy.Predicate when, ActionStrategy.ActionHandler then) + { + _mw.When(when).Then(then); + return this; + } + + /// Map a route: first predicate that matches returns the response. + public Builder Map(Strategy.Predicate when, Strategy.Handler then) + { + _routes.When(when).Then(then); + return this; + } + + /// Default route when nothing matches. + public Builder NotFound(Strategy.Handler handler) + { + _routes.Default(handler); + return this; + } + + /// Provide a custom content-negotiator (optional). + public Builder WithNegotiator(TryStrategy negotiator) + { + _neg = negotiator; + return this; + } + + public MiniRouter Build() + { + // Middleware default: do nothing if nothing matched + _mw.Default(static (in _) => { }); + + var mw = _mw.Build(); + var routes = _routes.Build(); + var neg = _neg ?? DefaultNegotiator(); + return new MiniRouter(mw, routes, neg); + } + + private static TryStrategy DefaultNegotiator() + { + // Tiny Accept negotiator: + // - if Accept contains "application/json" -> pick json + // - if Accept contains "text/plain" -> pick text + // - else default to json + return TryStrategy.Create() + .Always(static (in r, out ct) => + { + if (r.Headers.TryGetValue("Accept", out var a) && + a.Contains("application/json", StringComparison.OrdinalIgnoreCase)) + { + ct = "application/json; charset=utf-8"; + return true; + } + + ct = null; + return false; + }) + .Or.Always(static (in r, out ct) => + { + if (r.Headers.TryGetValue("Accept", out var a) && + a.Contains("text/plain", StringComparison.OrdinalIgnoreCase)) + { + ct = "text/plain; charset=utf-8"; + return true; + } + + ct = null; + return false; + }) + .Finally(static (in _, out ct) => + { + ct = "application/json; charset=utf-8"; + return true; + }) + .Build(); + } + } +} \ No newline at end of file diff --git a/src/PatternKit.Examples/Chain/AuthLoggingDemo.cs b/src/PatternKit.Examples/Chain/AuthLoggingDemo.cs new file mode 100644 index 0000000..e8595e1 --- /dev/null +++ b/src/PatternKit.Examples/Chain/AuthLoggingDemo.cs @@ -0,0 +1,132 @@ +using PatternKit.Behavioral.Chain; + +namespace PatternKit.Examples.Chain; + +/// +/// Minimal HTTP-ish request used by the chain. +/// +/// HTTP method, e.g., GET, POST. +/// Request path beginning with / (e.g., /admin/stats). +/// Request headers (case sensitivity is determined by the provided dictionary). +/// +/// This is a tiny, immutable record struct meant to keep the example focused on chain composition rather than I/O. +/// +public readonly record struct HttpRequest(string Method, string Path, IReadOnlyDictionary Headers); + +/// +/// Minimal HTTP-ish response. Included for completeness; not used by this specific demo. +/// +/// HTTP status code. +/// Payload body (if any). +public readonly record struct HttpResponse(int Status, string Body); + +/// +/// Demonstrates an over that composes +/// request-id logging and an auth gate for /admin/* without if/else ladders. +/// +/// +/// +/// The chain shows three kinds of steps: +/// +/// +/// +/// +/// Conditional log (continue): If X-Request-Id is present, +/// log reqid=<id> and continue (via ). +/// +/// +/// +/// +/// Auth gate (stop): If the path starts with /admin and no Authorization header exists, +/// log deny: missing auth and stop the chain early +/// (via ). +/// +/// +/// +/// +/// Tail log (finally-on-continue): In +/// we log {Method} {Path}. This runs only if no prior step called ThenStop +/// (strict-stop semantics). In other words, a Stop short-circuits the chain and +/// prevents this tail from running. +/// +/// +/// +/// +/// If you want method/path logging to run even when a stop occurs, move that logic into an +/// .Always(...) step (if available in your version of PatternKit) or emit the log inside each +/// stop branch explicitly. +/// +/// +/// This demo executes two simulated requests: +/// +/// +/// +/// GET /health (no stop) → tail log runs. +/// +/// +/// GET /admin/metrics (missing auth) → stop after deny; tail log does not run. +/// +/// +/// +/// +/// +/// // Produces two log lines with strict-stop semantics: +/// // 1) "GET /health" +/// // 2) "deny: missing auth" +/// var lines = AuthLoggingDemo.Run(); +/// +/// +/// +/// +public static class AuthLoggingDemo +{ + /// + /// Builds and executes the demo chain, returning the emitted log lines. + /// + /// + /// A list of log lines in execution order. With strict-stop semantics the result is: + /// + /// GET /health + /// deny: missing auth + /// + /// + /// + /// + /// The chain is built once and executed twice against in-memory instances. + /// No I/O is performed; the focus is on composition and control flow. + /// + /// + /// Why strict-stop? It keeps “deny” paths clean and predictable—once you stop, nothing else runs. + /// To force logging regardless of stop/continue, prefer an explicit .Always(...) step (if present) + /// or duplicate the necessary log in the stop branch. + /// + /// + public static List Run() + { + var log = new List(); + + var chain = ActionChain.Create() + // request id (continue) + .When(static (in r) => r.Headers.ContainsKey("X-Request-Id")) + .ThenContinue(r => log.Add($"reqid={r.Headers["X-Request-Id"]}")) + + // admin requires auth (stop) + .When(static (in r) => r.Path.StartsWith("/admin", StringComparison.Ordinal) + && !r.Headers.ContainsKey("Authorization")) + .ThenStop(r => log.Add("deny: missing auth")) + + // tail log (runs only if the chain wasn't stopped earlier) + .Finally((in r, next) => + { + log.Add($"{r.Method} {r.Path}"); + next(r); // terminal "next" is a no-op + }) + .Build(); + + // simulate + chain.Execute(new HttpRequest("GET", "/health", new Dictionary())); + chain.Execute(new HttpRequest("GET", "/admin/metrics", new Dictionary())); + + return log; // ["GET /health", "deny: missing auth"] + } +} \ No newline at end of file diff --git a/src/PatternKit.Examples/Chain/ConfigDriven/ConfigDrivenPipelineBuilderExtensions.cs b/src/PatternKit.Examples/Chain/ConfigDriven/ConfigDrivenPipelineBuilderExtensions.cs new file mode 100644 index 0000000..367c4de --- /dev/null +++ b/src/PatternKit.Examples/Chain/ConfigDriven/ConfigDrivenPipelineBuilderExtensions.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.Options; +using PatternKit.Behavioral.Chain; + +namespace PatternKit.Examples.Chain.ConfigDriven; + +public static class ConfigDrivenPipelineBuilderExtensions +{ + /// + /// Recompute subtotal, apply configured discount rules in order, then compute tax. + /// + public static TransactionPipelineBuilder AddConfigDrivenDiscountsAndTax( + this TransactionPipelineBuilder b, + IOptions opts, + IEnumerable discountRules) + { + // Build a stable map once (case-insensitive) + var map = discountRules.ToDictionary(r => r.Key, r => r, StringComparer.OrdinalIgnoreCase); + var chain = ActionChain.Create() + .Use(static (in c, next) => + { + c.RecomputeSubtotal(); + c.Log.Add($"subtotal: {c.Subtotal:C2}"); + next(in c); + }) + .Finally((in c, next) => + { + foreach (var key in opts.Value.DiscountRules) + if (map.TryGetValue(key, out var rule)) + rule.Apply(c); + + var taxable = Math.Max(0m, c.Subtotal - c.DiscountTotal); + var tax = Math.Round(taxable * 0.0875m, 2); + c.SetTax(tax); + c.Log.Add($"pre-round total: {c.GrandTotal:C2}"); + next(in c); + }) + .Build(); + + return b.AddStage(chain); + } + + /// + /// Apply configured rounding strategies in order (first-match-wins semantics live inside each strategy). + /// + public static TransactionPipelineBuilder AddConfigDrivenRounding( + this TransactionPipelineBuilder b, + IOptions opts, + IEnumerable rounding) + { + var map = rounding.ToDictionary(r => r.Key, r => r, StringComparer.OrdinalIgnoreCase); + var chain = ActionChain.Create() + .Finally((in c, next) => + { + foreach (var key in opts.Value.Rounding) + if (map.TryGetValue(key, out var strat)) + strat.Apply(c); + + c.Log.Add($"total: {c.GrandTotal:C2}"); + next(in c); + }) + .Build(); + + return b.AddStage(chain); + } +} diff --git a/src/PatternKit.Examples/Chain/ConfigDriven/TransactionPipelineDemo.cs b/src/PatternKit.Examples/Chain/ConfigDriven/TransactionPipelineDemo.cs new file mode 100644 index 0000000..d731d59 --- /dev/null +++ b/src/PatternKit.Examples/Chain/ConfigDriven/TransactionPipelineDemo.cs @@ -0,0 +1,366 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace PatternKit.Examples.Chain.ConfigDriven; + +// ---- Strategy contracts ---- + +/// +/// Represents a discount rule that can mutate a by applying a discount. +/// +/// +/// Implementations should be deterministic and idempotent within a single pipeline run. +/// +public interface IDiscountRule +{ + /// + /// Unique key used to reference this rule from configuration (e.g., "discount:cash-2pc"). + /// + string Key { get; } + + /// + /// Applies the discount to the provided (if applicable). + /// + /// The transaction context. + void Apply(TransactionContext ctx); +} + +/// +/// Represents a rounding strategy that can adjust the via rounding. +/// +public interface IRoundingStrategy +{ + /// + /// Unique key used to reference this strategy from configuration (e.g., "round:charity"). + /// + string Key { get; } + + /// + /// Applies rounding to the provided (if applicable). + /// + /// The transaction context. + void Apply(TransactionContext ctx); +} + +/// +/// Handler that determines if it can process a given tender and performs the handling. +/// +public interface ITenderHandler +{ + /// + /// Unique key for display/configuration (e.g., "tender:cash", "tender:card"). + /// + string Key { get; } // e.g. "cash", "card:visa", etc. + + /// + /// Returns if this handler can process in . + /// + /// The transaction context. + /// The tender candidate. + bool CanHandle(TransactionContext ctx, Tender t); + + /// + /// Performs tender handling and returns the outcome. + /// + /// The transaction context. + /// The tender to process. + /// A describing the outcome. + TxResult Handle(TransactionContext ctx, Tender t); +} + +// ---- Config model (what to run & in what order) ---- + +/// +/// Options that describe which pipeline components to run and in what order. +/// +public sealed class PipelineOptions +{ + /// + /// Discount rule keys in execution order. + /// + public List DiscountRules { get; init; } = []; // keys in order + + /// + /// Rounding strategy keys in execution order. + /// + public List Rounding { get; init; } = []; // keys in order + + /// + /// Optional tender display order (purely informational). + /// + public List TenderOrder { get; init; } = []; // optional, for display +} + +// --- Discount rules --- + +/// +/// Applies a 2% discount when the first tender is cash. +/// +public sealed class Cash2Pct : IDiscountRule +{ + /// + public string Key => "discount:cash-2pc"; + + /// + public void Apply(TransactionContext ctx) + { + // FIRST tender being cash is a simple proxy for "cash-driven promo" + var first = ctx.Tenders.Count > 0 ? ctx.Tenders[0] : ctx.Tender; + if (first?.Kind == PaymentKind.Cash) + { + var off = Math.Round(ctx.Subtotal * 0.02m, 2); + ctx.AddDiscount(off, "cash 2% off"); + } + } +} + +/// +/// Applies a 5% discount when a loyalty ID is present. +/// +public sealed class Loyalty5Pct : IDiscountRule +{ + /// + public string Key => "discount:loyalty-5pc"; + + /// + public void Apply(TransactionContext ctx) + { + if (!string.IsNullOrWhiteSpace(ctx.Customer.LoyaltyId)) + { + var off = Math.Round(ctx.Subtotal * 0.05m, 2); + ctx.AddDiscount(off, $"loyalty {ctx.Customer.LoyaltyId}"); + } + } +} + +/// +/// Applies a $1 off per bundled item when the same bundle key reaches a quantity of two or more. +/// +public sealed class Bundle1OffEach : IDiscountRule +{ + /// + public string Key => "discount:bundle-1off"; + + /// + public void Apply(TransactionContext ctx) + { + var off = ctx.Items + .GroupBy(i => i.BundleKey) + .Where(g => g.Key is not null && g.Sum(i => i.Qty) >= 2) + .Sum(g => g.Sum(i => i.Qty) * 1.00m); + + if (off > 0) ctx.AddDiscount(off, "bundle deal"); + } +} + +// --- Rounding strategies --- + +/// +/// Rounds up to the next dollar when a "CHARITY:*" SKU is present. +/// +public sealed class CharityRoundUp : IRoundingStrategy +{ + /// + public string Key => "round:charity"; + + /// + public void Apply(TransactionContext ctx) + { + var charity = ctx.Items.FirstOrDefault(i => + i.Sku.StartsWith("CHARITY:", StringComparison.OrdinalIgnoreCase)); + if (charity is null) return; + + var up = Math.Ceiling(ctx.GrandTotal) - ctx.GrandTotal; + up = Math.Round(up, 2); + if (up > 0m) + ctx.ApplyRounding(up, $"charity {charity.Sku["CHARITY:".Length..]}"); + } +} + +/// +/// Rounds to the nearest nickel when the transaction is cash-only and includes the "ROUND:NICKEL" SKU. +/// +public sealed class NickelCashOnly : IRoundingStrategy +{ + /// + public string Key => "round:nickel-cash-only"; + + /// + public void Apply(TransactionContext ctx) + { + if (!ctx.IsCashOnlyTransaction) + { + ctx.Log.Add("round: skipped (not cash-only)"); + return; + } + + var rounded = Math.Round(ctx.GrandTotal * 20m, MidpointRounding.AwayFromZero) / 20m; + var delta = Math.Round(rounded - ctx.GrandTotal, 2); + if (delta != 0m) ctx.ApplyRounding(delta, "nickel (cash-only)"); + else ctx.Log.Add("round: nickel (cash-only) +$0.00"); + } +} + +// --- Tender handlers --- + +/// +/// Handles cash tenders by opening the drawer, applying payment, and computing change. +/// +public sealed class CashTender : ITenderHandler +{ + private readonly IDeviceBus _devices; + /// Creates a new cash tender handler. + public CashTender(IDeviceBus devices) => _devices = devices; + + /// + public string Key => "tender:cash"; + + /// + public bool CanHandle(TransactionContext ctx, Tender t) => t.Kind == PaymentKind.Cash; + + /// + public TxResult Handle(TransactionContext ctx, Tender t) + { + _devices.OpenCashDrawer(); + var applied = Math.Min(t.CashGiven, ctx.RemainderDue); + ctx.ApplyPayment(applied, "cash"); + + var leftover = Math.Round(t.CashGiven - applied, 2); + if (leftover > 0m) + { + ctx.CashChange = (ctx.CashChange ?? 0m) + leftover; + ctx.Log.Add($"cash: change {leftover:C2}"); + } + + return TxResult.Success("cash", "ok"); + } +} + +/// +/// Handles card tenders by authorizing and capturing the remainder due. +/// +public sealed class CardTender : ITenderHandler +{ + private readonly CardProcessors _processors; + /// Creates a new card tender handler. + public CardTender(CardProcessors processors) => _processors = processors; + + /// + public string Key => "tender:card"; + + /// + public bool CanHandle(TransactionContext ctx, Tender t) => t.Kind == PaymentKind.Card; + + /// + public TxResult Handle(TransactionContext ctx, Tender t) + { + ctx.AuthorizationAmount = ctx.RemainderDue; + if (ctx.AuthorizationAmount <= 0m) return TxResult.Success("card", "nothing to pay"); + + var proc = _processors.Resolve(t.Vendor); + var auth = proc.Authorize(ctx); + if (!auth.Ok) + { + ctx.Log.Add($"auth: declined ({auth.Code})"); + return auth; + } + + var cap = proc.Capture(ctx); + if (!cap.Ok) + { + ctx.Log.Add("auth: capture failed"); + return cap; + } + + ctx.ApplyPayment(ctx.AuthorizationAmount, + $"card {t.Vendor} {t.AuthType?.ToString() ?? "Unknown"}"); + ctx.Log.Add($"auth: captured via {t.Vendor} {ctx.AuthorizationAmount:C2}"); + return TxResult.Success("card", "ok"); + } +} + +/// +/// DI-friendly configuration and registration helpers for the config-driven transaction pipeline. +/// +public static class ConfigDrivenPipelineDemo +{ + /// + /// Registers a config-driven transaction pipeline into the service collection and returns it. + /// + /// The service collection to add services to. + /// Application configuration containing "Payment:Pipeline" section. + /// The same for chaining. + /// + /// This method wires up device bus, card processors, strategies (discounts, rounding, tenders), + /// and builds a shared using . + /// + public static IServiceCollection AddPaymentPipeline(this IServiceCollection services, IConfiguration config) + { + services.AddOptions() + .Bind(config.GetSection("Payment:Pipeline")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + // Core infra + services.AddSingleton(); + services.AddSingleton(new CardProcessors(new() + { + [CardVendor.Visa] = new GenericProcessor("VisaNet"), + [CardVendor.Mastercard] = new GenericProcessor("MC"), + [CardVendor.Amex] = new GenericProcessor("Amex"), + [CardVendor.Chase] = new GenericProcessor("ChaseNet"), + [CardVendor.InHouse] = new GenericProcessor("InHouse"), + [CardVendor.Unknown] = new GenericProcessor("FallbackNet"), + })); + + // Register strategies (keyed by Key) + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + + // Build and register a shared TransactionPipeline from config + strategies + services.AddSingleton(sp => + { + var devices = sp.GetRequiredService(); + var opts = sp.GetRequiredService>(); + var discountRules = sp.GetServices(); + var rounding = sp.GetServices(); + var tenderHandlers = sp.GetServices().ToArray(); + + return TransactionPipelineBuilder.New() + .WithDeviceBus(devices) + .AddPreauth() + .AddConfigDrivenDiscountsAndTax(opts, discountRules) + .AddConfigDrivenRounding(opts, rounding) + .WithTenderHandlers(tenderHandlers) + .AddTenderHandling() + .AddFinalize() + .Build(); + }); + + services.AddSingleton(); + + return services; + } + + /// + /// Thin wrapper so existing callers can keep using PaymentPipeline.Run(ctx). + /// + public sealed class PaymentPipeline(TransactionPipeline pipeline) + { + /// + /// Executes the registered pipeline against the provided context. + /// + /// The transaction context to process. + /// The terminal result and the (mutated) context. + public (TxResult Result, TransactionContext Ctx) Run(TransactionContext ctx) + => pipeline.Run(ctx); + } +} \ No newline at end of file diff --git a/src/PatternKit.Examples/Chain/MediatedTransactionPipelineDemo.cs b/src/PatternKit.Examples/Chain/MediatedTransactionPipelineDemo.cs new file mode 100644 index 0000000..e929e89 --- /dev/null +++ b/src/PatternKit.Examples/Chain/MediatedTransactionPipelineDemo.cs @@ -0,0 +1,958 @@ +using PatternKit.Behavioral.Chain; +using PatternKit.Creational.Builder; +using PatternKit.Examples.Chain.ConfigDriven; + +namespace PatternKit.Examples.Chain; + +/// +/// Represents a single pipeline stage that mutates a and signals whether +/// the pipeline should continue () or stop (). +/// +/// The working transaction context being processed. +/// to continue with the next stage; to stop the pipeline. +public delegate bool Stage(TransactionContext ctx); + +// ---------- Tender router (BranchBuilder-powered strategy selection) ---------- + +/// +/// Predicate used by the tender router to decide whether a handler can process a specific tender. +/// +/// The current transaction context (passed by for performance). +/// The tender candidate (passed by for performance). +/// if the handler should handle the tender; otherwise . +public delegate bool TenderPred(in TransactionContext c, in Tender t); + +/// +/// Handler step invoked by the tender router once a predicate matches. +/// +/// The transaction context. +/// The tender to handle. +/// A describing the outcome of handling the tender. +public delegate TxResult TenderStep(TransactionContext c, in Tender t); + +/// +/// A composite delegate that routes a tender to an appropriate handler. +/// +/// The transaction context. +/// The tender to route. +/// The returned by the matched handler or a failure result if none match. +public delegate TxResult TenderRouter(TransactionContext c, in Tender t); + +/// +/// Factory that composes a from a sequence of instances +/// using a branch builder for zero-if routing. +/// +public static class TenderRouterFactory +{ + /// + /// Builds a that evaluates handlers in registration order and uses the first + /// handler whose returns . + /// + /// The handlers to consider for routing (order matters). + /// A fast router function that encapsulates predicate checks and handler execution. + public static TenderRouter Build(IEnumerable handlers) + { + var bb = BranchBuilder.Create(); + + foreach (var handler in handlers) + { + var h = handler; // avoid modified-closure issue + bb.Add( + // wrap CanHandle so it matches: (in TransactionContext, in Tender) -> bool + (in c, in t) => h.CanHandle(c, t), + // wrap Handle so it matches: (TransactionContext, in Tender) -> TxResult + (c, in t) => h.Handle(c, t) + ); + } + + return bb.Build( + fallbackDefault: static (ctx, in t) + => TxResult.Fail("route", $"no handler for {t.Kind}"), + projector: static (predicates, steps, _, @default) => + { + return (ctx, in t) => + { + for (var i = 0; i < predicates.Length; i++) + if (predicates[i](in ctx, in t)) + return steps[i](ctx, in t); + + return @default(ctx, in t); + }; + }); + } +} + +// ---------- Mini helper to wrap ActionChain into a Stage ---------- + +/// +/// Helpers for adapting pipelines to delegates. +/// +public static class ChainStage +{ + /// + /// Wraps an so it can be used as a pipeline . + /// + /// The action chain to execute. + /// A stage that executes the chain and continues unless it set a failing . + public static Stage From(ActionChain chain) + => ctx => + { + chain.Execute(ctx); + return ctx.Result?.Ok != false; + }; +} + +// ---------- Builder that composes the pipeline declaratively ---------- + +/// +/// A small, immutable runner for a composed transaction pipeline. +/// +/// The ordered list of stages to execute. +public sealed class TransactionPipeline(Stage[] stages) +{ + /// + /// Executes the pipeline against the provided context. + /// + /// The transaction context to process. + /// + /// A tuple containing the terminal and the (mutated) . + /// If no stage produced a terminal result, the outcome is forced to paid. + /// + public (TxResult Result, TransactionContext Ctx) Run(TransactionContext ctx) + => stages.Any(s => !s(ctx)) + ? (ctx.Result!.Value, ctx) + : (ctx.Result ?? TxResult.Success("paid", "paid in full"), ctx); +} + +/// +/// Fluent builder that composes a production-grade transaction pipeline using small, focused stages. +/// +public sealed class TransactionPipelineBuilder +{ + private readonly ChainBuilder _chain = ChainBuilder.Create(); + + // deps + private IDeviceBus _devices = new DeviceBus(); + private readonly List _tenderHandlers = []; + + private IReadOnlyList _roundingRules = + [ + new CharityRoundUpRule(new NoopCharityTracker()), + new NickelCashOnlyRule() + ]; + + /// + /// Creates a new builder with sensible defaults. + /// + public static TransactionPipelineBuilder New() => new(); + + /// + /// Injects the device bus used by cash handling and completion beeps. + /// + /// The device bus implementation. + /// The same builder for fluent chaining. + public TransactionPipelineBuilder WithDeviceBus(IDeviceBus devices) + { + _devices = devices; + return this; + } + + /// + /// Supplies a set of implementations used by the tender router. + /// + /// Handlers to add in the given order. + /// The same builder for fluent chaining. + public TransactionPipelineBuilder WithTenderHandlers(params ITenderHandler[] handlers) + { + _tenderHandlers.AddRange(handlers); + return this; + } + + /// + /// Overrides the default rounding rules applied by . + /// + /// A list of rules evaluated in order (first-match wins). + /// The same builder for fluent chaining. + public TransactionPipelineBuilder WithRoundingRules(params IRoundingRule[] rules) + { + _roundingRules = rules; + return this; + } + + /// + /// Appends an arbitrary stage to the pipeline. + /// + /// The stage delegate. + /// The same builder for fluent chaining. + public TransactionPipelineBuilder AddStage(Stage stage) + { + _chain.Add(stage); + return this; + } + + /// + /// Appends an stage to the pipeline. + /// + /// The action chain to adapt. + /// The same builder for fluent chaining. + public TransactionPipelineBuilder AddStage(ActionChain chain) + => AddStage(ChainStage.From(chain)); + + + // --- PREAUTH via ActionChain (no if/else) -------------------------------- + /// + /// Adds pre-authorization checks (age restriction, non-empty basket) without if/else branching. + /// + /// + /// If any rule fails, a terminal failure result is set and the pipeline stops. + /// + public TransactionPipelineBuilder AddPreauth() + { + var preauth = ActionChain.Create() + .When(static (in c) => c.Items.Any(i => i.AgeRestricted) && c.Customer.AgeYears < 21) + .ThenStop(static c => + { + c.Result = TxResult.Fail("age", "age verification failed"); + c.Log.Add("preauth: blocked by age restriction"); + }) + .When(static (in c) => c.Items.Count == 0) + .ThenStop(static c => + { + c.Result = TxResult.Fail("empty", "no items"); + c.Log.Add("preauth: empty basket"); + }) + .Finally(static (in c, next) => + { + c.Log.Add("preauth: ok"); + next(in c); + }) + .Build(); + + _chain.Add(ChainStage.From(preauth)); + return this; + } + + // --- DISCOUNTS & TAX via ActionChain (no if/else) ------------------------ + /// + /// Adds subtotal computation, discount policies, and tax calculation as a branchless action chain. + /// + /// + /// The following discount examples are included: cash 2% off (if cash-first), loyalty 5% off, + /// manufacturer coupons, in-house coupons, and a bundle deal when two or more items share a bundle key. + /// + public TransactionPipelineBuilder AddDiscountsAndTax() + { + var totals = ActionChain.Create() + .Use(static (in c, next) => + { + c.RecomputeSubtotal(); + c.Log.Add($"subtotal: {c.Subtotal:C2}"); + next(in c); + }) + .When(static (in c) => (c.Tenders.Count > 0 ? c.Tenders[0] : c.Tender)?.Kind == PaymentKind.Cash) + .ThenContinue(static c => + { + var off = Math.Round(c.Subtotal * 0.02m, 2); + c.AddDiscount(off, "cash 2% off"); + }) + .When(static (in c) => !string.IsNullOrWhiteSpace(c.Customer.LoyaltyId)) + .ThenContinue(static c => + { + var off = Math.Round(c.Subtotal * 0.05m, 2); + c.AddDiscount(off, $"loyalty {c.Customer.LoyaltyId}"); + }) + .When(static (in c) => c.Items.Any(i => i.ManufacturerCoupon > 0m)) + .ThenContinue(static c => + { + var off = c.Items.Sum(i => i.ManufacturerCoupon * i.Qty); + c.AddDiscount(off, "manufacturer coupons"); + }) + .When(static (in c) => c.Items.Any(i => i.InHouseCoupon > 0m)) + .ThenContinue(static c => + { + var off = c.Items.Sum(i => i.InHouseCoupon * i.Qty); + c.AddDiscount(off, "in-house coupons"); + }) + .When(static (in c) => + c.Items.GroupBy(i => i.BundleKey).Any(g => g.Key is not null && g.Sum(i => i.Qty) >= 2)) + .ThenContinue(static c => + { + var off = c.Items.GroupBy(i => i.BundleKey) + .Where(g => g.Key is not null && g.Sum(i => i.Qty) >= 2) + .Sum(g => g.Sum(i => i.Qty) * 1.00m); + c.AddDiscount(off, "bundle deal"); + }) + .Finally(static (in c, next) => + { + var taxable = Math.Max(0m, c.Subtotal - c.DiscountTotal); + var tax = Math.Round(taxable * 0.0875m, 2); + c.SetTax(tax); + c.Log.Add($"pre-round total: {c.GrandTotal:C2}"); + next(in c); + }) + .Build(); + + _chain.Add(ChainStage.From(totals)); + return this; + } + + // --- ROUNDING via strategy list ----------------------------------------- + /// + /// Adds a rounding stage that evaluates configured strategies in order + /// and applies the first that matches. + /// + public TransactionPipelineBuilder AddRounding() + { + _chain.Add(ctx => + { + RoundingPipeline.Apply(ctx, _roundingRules); + return true; + }); + return this; + } + + + // --- TENDER handling via BranchBuilder-powered router -------------------- + /// + /// Adds tender handling using a branch-built router. If no handlers are supplied, cash and card + /// defaults are registered automatically. + /// + public TransactionPipelineBuilder AddTenderHandling() + { + // if not supplied, default to your existing cash/card strategies + if (_tenderHandlers.Count == 0) + { + var processors = new CardProcessors(new() + { + [CardVendor.Visa] = new GenericProcessor("VisaNet"), + [CardVendor.Mastercard] = new GenericProcessor("MC"), + [CardVendor.Amex] = new GenericProcessor("Amex"), + [CardVendor.Chase] = new GenericProcessor("ChaseNet"), + [CardVendor.InHouse] = new GenericProcessor("InHouse"), + [CardVendor.Unknown] = new GenericProcessor("FallbackNet"), + }); + + _tenderHandlers.AddRange([ + new CashTender(_devices), + new CardTender(processors) + ]); + } + + var route = TenderRouterFactory.Build(_tenderHandlers); + + _chain.Add(ctx => + { + var tenders = ctx.Tenders.Count > 0 + ? ctx.Tenders + : ctx.Tender is null + ? new List() + : new List { ctx.Tender }; + + ctx.Result = tenders + .TakeWhile(_ => ctx.RemainderDue > 0m) + .Select(t => route(ctx, in t)) + .Cast() + .LastOrDefault(res => res is { Ok: true }); + + return ctx.Result is null or { Ok: true }; + }); + + return this; + } + + // --- finalize ------------------------------------------------------------ + /// + /// Adds a finalization stage that turns the remaining balance into a terminal result and performs + /// small side-effects like beeping a device. + /// + public TransactionPipelineBuilder AddFinalize() + { + var devices = _devices; + _chain.Add(ctx => + { + if (ctx.Result is { Ok: false }) return false; + + ctx.Result = ctx.RemainderDue > 0m + ? TxResult.Fail("insufficient", $"still due {ctx.RemainderDue:C2}") + : TxResult.Success("paid", "paid in full"); + + if (ctx.Result.Value.Ok) devices.Beep("printer", 2); + ctx.Log.Add("done."); + return true; + }); + return this; + } + + /// + /// Materializes the configured stages into a runnable . + /// + public TransactionPipeline Build() => _chain.Build(stages => new TransactionPipeline(stages)); +} + +// ---------- Domain ---------- + +/// +/// Supported payment kinds. +/// +public enum PaymentKind +{ + /// Cash payment at the point of sale. + Cash, + /// Card payment (debit/credit). + Card /*, Crypto, Check ...*/ +} + +/// +/// How a card authorization was performed. +/// +public enum CardAuthType +{ + /// Chip insert (EMV). + Chip, + /// Magnetic stripe swipe. + Swipe, + /// Contactless/NFC (tap). + Contactless +} + +/// +/// Card networks/vendors recognized by the demo. +/// +public enum CardVendor +{ + Visa, + Mastercard, + Amex, + Chase, + InHouse, + Unknown +} + +/// +/// Customer information relevant to the checkout flow. +/// +/// Optional loyalty identifier used by discounts. +/// Customer age in years for age-restricted checks. +public sealed record Customer(string? LoyaltyId, int AgeYears); + +/// +/// Represents an item being purchased. +/// +/// The item SKU; special SKUs (e.g., CHARITY:*) drive certain behaviors. +/// Unit price before discounts and taxes. +/// Quantity of units. +/// Whether the item is age restricted. +/// Optional key used to detect bundle promotions. +/// Manufacturer coupon amount per unit. +/// In-house coupon amount per unit. +public sealed record LineItem( + string Sku, + decimal UnitPrice, + int Qty = 1, + bool AgeRestricted = false, + string? BundleKey = null, + decimal ManufacturerCoupon = 0m, + decimal InHouseCoupon = 0m +); + +/// +/// Represents a payment attempt made by the customer. +/// +/// The payment kind. +/// Card authorization type (for card payments). +/// Card network/vendor (for card payments). +/// Cash presented by the customer (for cash payments). +public sealed record Tender( + PaymentKind Kind, + CardAuthType? AuthType = null, + CardVendor? Vendor = null, + decimal CashGiven = 0m +); + +/// +/// Mutable transaction state that flows through all pipeline stages. +/// +public sealed class TransactionContext +{ + /// + /// Unique transaction identifier. + /// + public Guid Id { get; } = Guid.NewGuid(); + + /// + /// Customer participating in the transaction. + /// + public required Customer Customer { get; init; } + + /// + /// Single tender used by legacy callers; prefer . + /// + public Tender? Tender { get; set; } + + /// + /// A list of tenders to process in order. + /// + public List Tenders { get; init; } = []; + + /// + /// Items being purchased. + /// + public required List Items { get; init; } + + // Running totals + /// The running subtotal before discounts and tax. + public decimal Subtotal { get; private set; } + /// Total amount discounted so far. + public decimal DiscountTotal { get; private set; } + /// Tax total computed for the purchase. + public decimal TaxTotal { get; private set; } + /// Rounding delta applied by rounding rules. + public decimal RoundingDelta { get; private set; } + + /// + /// Grand total including discounts, taxes, and rounding. + /// + public decimal GrandTotal => Subtotal - DiscountTotal + TaxTotal + RoundingDelta; + + // Tendering + /// Total amount paid across all tenders. + public decimal AmountPaid { get; private set; } + /// Remaining amount due after payments, never negative. + public decimal RemainderDue => Math.Max(0m, Math.Round(GrandTotal - AmountPaid, 2)); + /// Cash change due to the customer, if any. + public decimal? CashChange { get; set; } + + // Card processors read this per-authorization + /// + /// The amount to authorize for card payments for the current step. + /// + public decimal AuthorizationAmount { get; set; } + + // Side-effects & logs + /// + /// Human-readable log recording applied rules and decisions. + /// + public List Log { get; } = []; + + // Terminal outcome + /// + /// Terminal result set by stages. When set to a failing result the pipeline stops. + /// + public TxResult? Result { get; set; } + + /// + /// True when all configured tenders are cash; used for cash-only rounding. + /// + public bool IsCashOnlyTransaction => + (Tenders.Count > 0 + ? Tenders + : Tender is null + ? [] + : new List { Tender }) + .All(t => t.Kind == PaymentKind.Cash); + + /// + /// Recomputes the from . + /// + public void RecomputeSubtotal() => + Subtotal = Items.Sum(i => i.UnitPrice * i.Qty); + + /// + /// Adds a discount amount with a reason and updates the running total. + /// + /// The discount amount to add; ignored if not positive. + /// Human-readable reason recorded in . + public void AddDiscount(decimal amount, string reason) + { + if (amount <= 0m) return; + DiscountTotal += amount; + Log.Add($"discount: {reason} {amount:C2}"); + } + + /// + /// Sets the total tax and logs it. + /// + /// The computed tax amount. + public void SetTax(decimal amount) + { + TaxTotal = amount; + Log.Add($"tax: {amount:C2}"); + } + + /// + /// Applies a rounding delta and logs the change. + /// + /// The rounding delta (can be positive or negative). + /// Human-readable reason recorded in . + public void ApplyRounding(decimal delta, string reason) + { + if (delta == 0m) return; + RoundingDelta = Math.Round(delta, 2); + Log.Add($"round: {reason} {(delta >= 0 ? "+" : "")}{RoundingDelta:C2}"); + } + + /// + /// Applies a payment amount and logs the new remaining balance. + /// + /// The amount paid. + /// Descriptor of how the payment was made (e.g., "cash"). + public void ApplyPayment(decimal amount, string how) + { + if (amount <= 0m) return; + AmountPaid = Math.Round(AmountPaid + amount, 2); + Log.Add($"paid: {how} {amount:C2} (remaining {RemainderDue:C2})"); + } +} + +/// +/// Lightweight result for pipeline stages and handlers. +/// +/// True for success; false for failure. +/// Machine-readable code (e.g., "paid", "age"). +/// Human-readable message. +public readonly record struct TxResult(bool Ok, string Code, string Message) +{ + /// + /// Creates a success result. + /// + /// Optional success code (default: ok). + /// Optional message (default: approved). + public static TxResult Success(string code = "ok", string msg = "approved") => new(true, code, msg); + + /// + /// Creates a failure result. + /// + /// Failure code. + /// Human-readable message. + public static TxResult Fail(string code, string msg) => new(false, code, msg); +} + +// ---------- External devices / services (stubs) ---------- + +/// +/// Abstraction over external peripherals used by the pipeline (cash drawer, beeper, etc.). +/// +public interface IDeviceBus +{ + /// Opens the cash drawer. + /// Drawer number (default: 1). + void OpenCashDrawer(int drawer = 1); + + /// Beeps a device for a given duration. + /// Device name. + /// Arbitrary duration/units (default: 1). + void Beep(string device, int units = 1); +} + +/// +/// Minimal card processor abstraction used by the demo handlers. +/// +public interface ICardProcessor +{ + /// Performs a card authorization for . + TxResult Authorize(TransactionContext ctx); + /// Captures the previously authorized amount. + TxResult Capture(TransactionContext ctx); +} + +/// +/// No-op device bus implementation used by the sample pipeline. +/// +public sealed class DeviceBus : IDeviceBus +{ + /// + public void OpenCashDrawer(int drawer = 1) + { + /* talk to POS */ + } + + /// + public void Beep(string device, int units = 1) + { + /* talk to peripherals */ + } +} + +/// +/// Registry of instances keyed by . +/// +public sealed class CardProcessors +{ + private readonly Dictionary _map; + /// Creates a new registry. + public CardProcessors(Dictionary map) => _map = map; + + /// + /// Resolves a processor for the specified vendor, falling back to . + /// + public ICardProcessor Resolve(CardVendor? v) => + v is not null && _map.TryGetValue(v.Value, out var p) ? p : _map[CardVendor.Unknown]; +} + +// ---------- Demo processors ---------- + +/// +/// Simple demo processor that always approves authorization and capture. +/// +public sealed class GenericProcessor(string name) : ICardProcessor +{ + /// + public TxResult Authorize(TransactionContext ctx) => + TxResult.Success("auth", $"{name}: authorized {ctx.AuthorizationAmount:C2}"); + + /// + public TxResult Capture(TransactionContext ctx) => + TxResult.Success("capture", $"{name}: captured {ctx.AuthorizationAmount:C2}"); +} + +// ---------- Tender strategies (flat, declarative) ---------- + +/// +/// Strategy contract for declarative tender processing. +/// +public interface ITenderStrategy +{ + /// The payment kind this strategy supports. + PaymentKind Kind { get; } + + /// + /// Attempts to apply this tender to the context. Returns a failure result if tendering should stop. + /// Returns null to indicate success/continue. + /// + TxResult? TryApply(TransactionContext ctx, Tender tender); +} + +/// +/// Observability hook invoked when charity rounding is applied. +/// +public interface ICharityTracker +{ + /// Tracks a charity round-up event. + void Track(string charity, Guid transactionId, decimal delta, decimal newTotal); +} + +/// +/// No-op charity tracker used by the sample. +/// +public sealed class NoopCharityTracker : ICharityTracker +{ + /// + public void Track(string charity, Guid transactionId, decimal delta, decimal newTotal) + { + /* noop */ + } +} + +/// +/// Contract for precise rounding rules evaluated by the rounding pipeline. +/// +public interface IRoundingRule +{ + /// Human readable reason (used for logging). + string Reason { get; } + + /// Returns true if this rule should be considered for the current context. + bool ShouldApply(in TransactionContext c); + + /// Computes the delta to apply. Return 0m for no change. + decimal ComputeDelta(in TransactionContext c); + + /// Optional side-effects when the delta was actually applied. + void OnApplied(TransactionContext c, decimal appliedDelta) + { + } +} + +/// +/// Applies cash tenders, opening the drawer and calculating any change due. +/// +public sealed class CashTenderStrategy(IDeviceBus devices) : ITenderStrategy +{ + /// + public PaymentKind Kind => PaymentKind.Cash; + + /// + public TxResult? TryApply(TransactionContext ctx, Tender tender) + { + if (ctx.RemainderDue <= 0m) return null; + + devices.OpenCashDrawer(); + + var applied = Math.Min(tender.CashGiven, ctx.RemainderDue); + ctx.ApplyPayment(applied, "cash"); + + // change only if this cash overpays AFTER covering all due + var leftover = Math.Round(tender.CashGiven - applied, 2); + if (leftover > 0m) + { + ctx.CashChange = (ctx.CashChange ?? 0m) + leftover; + ctx.Log.Add($"cash: change {leftover:C2}"); + } + + return null; // success, continue + } +} + +/// +/// Applies card tenders by authorizing and capturing the current remainder. +/// +public sealed class CardTenderStrategy(CardProcessors processors) : ITenderStrategy +{ + /// + public PaymentKind Kind => PaymentKind.Card; + + /// + public TxResult? TryApply(TransactionContext ctx, Tender tender) + { + if (ctx.RemainderDue <= 0m) return null; + + ctx.AuthorizationAmount = ctx.RemainderDue; + if (ctx.AuthorizationAmount <= 0m) return null; + + var proc = processors.Resolve(tender.Vendor); + var auth = proc.Authorize(ctx); + if (!auth.Ok) + { + ctx.Log.Add($"auth: declined ({auth.Code})"); + return auth; + } + + var cap = proc.Capture(ctx); + if (!cap.Ok) + { + ctx.Log.Add("auth: capture failed"); + return cap; + } + + ctx.ApplyPayment(ctx.AuthorizationAmount, + $"card {tender.Vendor} {tender.AuthType?.ToString() ?? "Unknown"}"); + ctx.Log.Add($"auth: captured via {tender.Vendor} {ctx.AuthorizationAmount:C2}"); + return null; // success, continue + } +} + +/// +/// Small registry that resolves a single by . +/// +public sealed class TenderStrategyRegistry +{ + private readonly Dictionary _map; + + /// Creates a registry from the provided strategies. + public TenderStrategyRegistry(IEnumerable strategies) + => _map = strategies.ToDictionary(s => s.Kind); + + /// Resolves the strategy for the specified . + public ITenderStrategy Resolve(PaymentKind kind) + => _map.TryGetValue(kind, out var s) + ? s + : throw new InvalidOperationException($"No strategy for {kind}"); +} + +/// +/// Rounds up to the next dollar when a charity SKU is present and notifies a tracker. +/// +public sealed class CharityRoundUpRule(ICharityTracker tracker) : IRoundingRule +{ + /// + public string Reason => "charity round-up"; + + /// + public bool ShouldApply(in TransactionContext c) + => c.Items.Any(i => i.Sku.StartsWith("CHARITY:", StringComparison.OrdinalIgnoreCase)); + + /// + public decimal ComputeDelta(in TransactionContext c) + { + var delta = Math.Ceiling(c.GrandTotal) - c.GrandTotal; + return Math.Round(delta, 2); + } + + /// + public void OnApplied(TransactionContext c, decimal appliedDelta) + { + var charitySku = c.Items.First(i => i.Sku.StartsWith("CHARITY:", StringComparison.OrdinalIgnoreCase)).Sku; + var name = charitySku["CHARITY:".Length..]; + tracker.Track(name, c.Id, appliedDelta, c.GrandTotal + appliedDelta); + c.Log.Add($"charity: {name} notified for {appliedDelta:C2}"); + } +} + +/// +/// Rounds to the nearest nickel, but only for cash-only transactions that include the special SKU. +/// +public sealed class NickelCashOnlyRule : IRoundingRule +{ + /// + public string Reason => "nickel (cash-only)"; + + /// + public bool ShouldApply(in TransactionContext c) + => c.IsCashOnlyTransaction && c.Items.Any(i => string.Equals(i.Sku, "ROUND:NICKEL", StringComparison.OrdinalIgnoreCase)); + + /// + public decimal ComputeDelta(in TransactionContext c) + { + // Round to nearest 0.05 with midpoint away from zero + var rounded = Math.Round(c.GrandTotal * 20m, MidpointRounding.AwayFromZero) / 20m; + return Math.Round(rounded - c.GrandTotal, 2); + } +} + +/// +/// Evaluates a list of rounding rules and applies the first matching rule (first-match wins). +/// +public static class RoundingPipeline +{ + /// + /// Applies the first matching rounding rule and logs the resulting total (or that no rounding was applied). + /// + /// The transaction context. + /// Ordered list of rules to evaluate. + public static void Apply( + TransactionContext c, + IReadOnlyList rules) + { + foreach (var rule in rules) + { + if (!rule.ShouldApply(in c)) continue; + + var delta = rule.ComputeDelta(in c); + if (delta == 0m) continue; + + c.ApplyRounding(delta, rule.Reason); + rule.OnApplied(c, delta); // side-effects for this rule only + c.Log.Add($"total: {c.GrandTotal:C2}"); + return; // first-match-wins: stop after one rounding + } + + c.Log.Add("round: none"); + c.Log.Add($"total: {c.GrandTotal:C2}"); + } +} + +// ---------- Pipeline demo ---------- + +/// +/// Entry point that builds and runs the mediated transaction pipeline using in-code stages. +/// +public static class MediatedTransactionPipelineDemo +{ + /// + /// Builds the default pipeline and executes it against the provided . + /// + /// The transaction context to run. + /// The terminal result and the (mutated) context. + public static (TxResult Result, TransactionContext Ctx) Run(TransactionContext ctx) + { + var pipeline = TransactionPipelineBuilder.New() + .AddPreauth() + .AddDiscountsAndTax() + .AddRounding() + .AddTenderHandling() + .AddFinalize() + .Build(); + + return pipeline.Run(ctx); + } +} \ No newline at end of file diff --git a/src/PatternKit.Examples/Generators/StrategySpecs.cs b/src/PatternKit.Examples/Generators/StrategySpecs.cs new file mode 100644 index 0000000..c284352 --- /dev/null +++ b/src/PatternKit.Examples/Generators/StrategySpecs.cs @@ -0,0 +1,18 @@ +using PatternKit.Generators; + +namespace PatternKit.Examples.Generators; + +[GenerateStrategy(nameof(OrderRouter), typeof(char), StrategyKind.Action)] +public partial class OrderRouter +{ +} + +[GenerateStrategy(nameof(ScoreLabeler), typeof(int), typeof(string), StrategyKind.Result)] +public partial class ScoreLabeler +{ +} + +[GenerateStrategy(nameof(IntParser), typeof(string), typeof(int), StrategyKind.Try)] +public partial class IntParser +{ +} \ No newline at end of file diff --git a/src/PatternKit.Examples/PatternKit.Examples.csproj b/src/PatternKit.Examples/PatternKit.Examples.csproj index 3813503..c018bd1 100644 --- a/src/PatternKit.Examples/PatternKit.Examples.csproj +++ b/src/PatternKit.Examples/PatternKit.Examples.csproj @@ -1,14 +1,30 @@  - net9.0 - latest - enable - enable + net8.0;net9.0 - + + + + + + + + + + + + + + + + + diff --git a/src/PatternKit.Examples/Properties/AssemblyCoverage.cs b/src/PatternKit.Examples/Properties/AssemblyCoverage.cs new file mode 100644 index 0000000..598a1a7 --- /dev/null +++ b/src/PatternKit.Examples/Properties/AssemblyCoverage.cs @@ -0,0 +1,4 @@ +#if NETSTANDARD2_1 +// Exclude the entire assembly from coverage when built for netstandard2.1 +[assembly: System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +#endif \ No newline at end of file diff --git a/src/PatternKit.Examples/Coercion/Coercer.cs b/src/PatternKit.Examples/Strategies/Coercion/Coercer.cs similarity index 97% rename from src/PatternKit.Examples/Coercion/Coercer.cs rename to src/PatternKit.Examples/Strategies/Coercion/Coercer.cs index a9fca36..38ac09a 100644 --- a/src/PatternKit.Examples/Coercion/Coercer.cs +++ b/src/PatternKit.Examples/Strategies/Coercion/Coercer.cs @@ -20,8 +20,8 @@ namespace PatternKit.Examples.Coercion; /// At runtime, performs: /// /// -/// Fast path: direct cast when is already . -/// Strategy chain execution: first matching handler transforms to . +/// Fast path: direct cast when input is already . +/// Strategy chain execution: first matching handler transforms input to . /// Default: returns when no handler matches. /// /// @@ -34,7 +34,7 @@ namespace PatternKit.Examples.Coercion; /// boolean → . /// /// -/// Convertible fallback: if is and the target underlying type is +/// Convertible fallback: if input is and the target underlying type is /// primitive or , uses with /// . /// @@ -171,7 +171,7 @@ private static bool FromJsonString(in object v, out T? r) /// private static bool FromJsonStringArray(in object v, out T? r) { - if (v is JsonElement je && je.ValueKind == JsonValueKind.Array) + if (v is JsonElement { ValueKind: JsonValueKind.Array } je) { var list = new List(); foreach (var e in je.EnumerateArray()) list.Add(e.ToString()); diff --git a/src/PatternKit.Examples/Strategies/Composed/ComposedStrategies.cs b/src/PatternKit.Examples/Strategies/Composed/ComposedStrategies.cs new file mode 100644 index 0000000..6c52a78 --- /dev/null +++ b/src/PatternKit.Examples/Strategies/Composed/ComposedStrategies.cs @@ -0,0 +1,417 @@ + +using PatternKit.Behavioral.Strategy; + +namespace PatternKit.Examples.Strategies.Composed; + +/// +/// Delivery channels supported by the composed strategies example. +/// +public enum Channel +{ + /// Email channel. + Email, + /// SMS (text message) channel. + Sms, + /// Push notification channel. + Push, + /// Instant messaging (IM) channel. + Im +} + +/// +/// Input passed to the composed strategies when attempting a send. +/// +/// The user receiving the message. +/// The message body to deliver. +/// +/// Whether the message is critical. Critical messages may alter ordering (e.g., prepend SMS). +/// +/// +/// Optional locale hint for downstream handlers (not used in this example). +/// +public sealed record SendContext(Guid UserId, string Message, bool IsCritical, string? Locale = null); + +/// +/// Result returned by channel handlers indicating outcome and metadata. +/// +/// The channel that was ultimately chosen / attempted. +/// Whether the send operation succeeded. +/// Optional diagnostic or provider-supplied information. +public readonly record struct SendResult(Channel Channel, bool Success, string? Info = null); + +#region Dependencies + +/// +/// Identity/consent checks that may gate specific channels (e.g., verified email, SMS opt-in). +/// Implementations may be synchronous or asynchronous. +/// +public interface IIdentityService +{ + /// Returns whether the user has a verified email address. + /// User identifier. + /// Cancellation token. + ValueTask HasVerifiedEmailAsync(Guid userId, CancellationToken ct); + + /// Returns whether the user has opted in to receive SMS. + /// User identifier. + /// Cancellation token. + ValueTask HasSmsOptInAsync(Guid userId, CancellationToken ct); + + /// Returns whether the user has a registered push token. + /// User identifier. + /// Cancellation token. + ValueTask HasPushTokenAsync(Guid userId, CancellationToken ct); +} + +/// +/// Presence/availability checks (e.g., IM online, Do-Not-Disturb). +/// Implementations may be synchronous or asynchronous. +/// +public interface IPresenceService +{ + /// Returns whether the user is currently online in IM. + /// User identifier. + /// Cancellation token. + ValueTask IsOnlineInImAsync(Guid userId, CancellationToken ct); + + /// Returns whether the user is currently in Do-Not-Disturb mode. + /// User identifier. + /// Cancellation token. + ValueTask IsDoNotDisturbAsync(Guid userId, CancellationToken ct); +} + +/// +/// Per-channel rate limiting abstraction. +/// +public interface IRateLimiter +{ + /// Returns whether the given may send to now. + /// Channel being evaluated. + /// User identifier. + /// Cancellation token. + ValueTask CanSendAsync(Channel channel, Guid userId, CancellationToken ct); +} + +/// +/// Retrieves user-preferred channel ordering. +/// +public interface IPreferenceService +{ + /// + /// Gets the user's preferred channel order. The strategy will deduplicate while preserving + /// first occurrences and evaluate gates in that order. + /// + /// User identifier. + /// Cancellation token. + /// Zero or more channels, ordered by preference. + ValueTask GetPreferredOrderAsync(Guid userId, CancellationToken ct); +} + +/// Email sender. +public interface IEmailSender +{ + /// Sends an email using the supplied context. + ValueTask SendAsync(SendContext ctx, CancellationToken ct); +} + +/// SMS sender. +public interface ISmsSender +{ + /// Sends an SMS using the supplied context. + ValueTask SendAsync(SendContext ctx, CancellationToken ct); +} + +/// Push notification sender. +public interface IPushSender +{ + /// Sends a push notification using the supplied context. + ValueTask SendAsync(SendContext ctx, CancellationToken ct); +} + +/// Instant messaging (IM) sender. +public interface IImSender +{ + /// Sends an IM using the supplied context. + ValueTask SendAsync(SendContext ctx, CancellationToken ct); +} + +#endregion + +/// +/// Bundles the gate (eligibility checks) and send handler for a channel. +/// +/// +/// A policy is composed of: +/// +/// : an that returns true when all checks pass. +/// : the handler invoked if the gate allows the channel. +/// +/// +public readonly struct ChannelPolicy +{ + /// + /// Gate strategy that returns only when all checks pass for the channel. + /// + public readonly AsyncStrategy Gate; + + /// + /// Send handler invoked when the channel is selected (after the gate allows it). + /// + public readonly AsyncStrategy.Handler Send; + + /// + /// Creates a new with the specified and handler. + /// + public ChannelPolicy( + AsyncStrategy gate, + AsyncStrategy.Handler send) + { + Gate = gate; + Send = send; + } +} + +/// +/// Factory that constructs gate strategies and send handlers for all channels, +/// wiring in the required services. +/// +/// Identity/consent service. +/// Presence service (IM online, DND). +/// Per-channel rate limiter. +/// Email sender. +/// SMS sender. +/// Push sender. +/// IM sender. +/// +/// Instances are lightweight. Call once and reuse the resulting policies. +/// +public sealed class ChannelPolicyFactory( + IIdentityService id, + IPresenceService presence, + IRateLimiter rate, + IEmailSender email, + ISmsSender sms, + IPushSender push, + IImSender im +) +{ + /// + /// Builds policies (gate + send) for every . + /// + /// A dictionary keyed by containing ready-to-use policies. + /// + /// Gates are composed so that all checks must pass (short-circuiting on first failure). + /// + public IReadOnlyDictionary CreateAll() + { + // Build once. Explicit delegate arrays avoid inference pitfalls and limit allocations. + var pushGate = Gate([HasPushToken, NotDnd, RateOkPush]); + var imGate = Gate([OnlineIm, RateOkIm]); + var emailGate = Gate([HasVerifiedEmail, RateOkEmail]); + var smsGate = Gate([HasSmsOptIn, RateOkSms]); + + return new Dictionary + { + [Channel.Push] = new(pushGate, SendPush), + [Channel.Im] = new(imGate, SendIm), + [Channel.Email] = new(emailGate, SendEmail), + [Channel.Sms] = new(smsGate, SendSms), + }; + } + + // ---------- Guards (named methods; no inline lambdas) ---------- + private ValueTask HasPushToken(SendContext c, CancellationToken ct) => id.HasPushTokenAsync(c.UserId, ct); + private ValueTask NotDnd(SendContext c, CancellationToken ct) => presence.IsDoNotDisturbAsync(c.UserId, ct).Continue(Not); + private ValueTask RateOkPush(SendContext c, CancellationToken ct) => rate.CanSendAsync(Channel.Push, c.UserId, ct); + + private ValueTask OnlineIm(SendContext c, CancellationToken ct) => presence.IsOnlineInImAsync(c.UserId, ct); + private ValueTask RateOkIm(SendContext c, CancellationToken ct) => rate.CanSendAsync(Channel.Im, c.UserId, ct); + + private ValueTask HasVerifiedEmail(SendContext c, CancellationToken ct) => id.HasVerifiedEmailAsync(c.UserId, ct); + private ValueTask RateOkEmail(SendContext c, CancellationToken ct) => rate.CanSendAsync(Channel.Email, c.UserId, ct); + + private ValueTask HasSmsOptIn(SendContext c, CancellationToken ct) => id.HasSmsOptInAsync(c.UserId, ct); + private ValueTask RateOkSms(SendContext c, CancellationToken ct) => rate.CanSendAsync(Channel.Sms, c.UserId, ct); + + private static bool Not(bool v) => !v; + + // ---------- Senders (method groups) ---------- + private ValueTask SendPush(SendContext c, CancellationToken ct) => push.SendAsync(c, ct); + private ValueTask SendIm(SendContext c, CancellationToken ct) => im.SendAsync(c, ct); + private ValueTask SendEmail(SendContext c, CancellationToken ct) => email.SendAsync(c, ct); + private ValueTask SendSms(SendContext c, CancellationToken ct) => sms.SendAsync(c, ct); + + /// + /// Creates a gate strategy that returns only if all + /// supplied checks pass (short-circuiting on first failure). + /// + /// Checks to evaluate sequentially. + /// An producing . + /// + /// The resulting strategy: + /// + /// Evaluates each check sequentially, respecting short-circuit semantics. + /// Returns on the first failure. + /// Returns if all checks pass. + /// + /// + private static AsyncStrategy Gate( + IEnumerable>> allOf) + { + var checks = allOf as Func>[] ?? allOf.ToArray(); + + return AsyncStrategy.Create() + .When(AnyFailureAsync) + .Then(ReturnFalse) // had a failure → NOT allowed + .Default(ReturnTrue) // no failures → allowed + .Build(); + + static ValueTask ReturnFalse(SendContext _, CancellationToken __) => new(false); + static ValueTask ReturnTrue(SendContext _, CancellationToken __) => new(true); + + async ValueTask AnyFailureAsync(SendContext c, CancellationToken ct) + { + // Sequential checks to preserve short-circuit semantics; easy to switch to parallel if desired. + foreach (var check in checks) + if (!await check(c, ct).ConfigureAwait(false)) + return true; + return false; + } + } +} + +/// +/// Entry point for building the composed, preference-aware strategy. +/// +public static class ComposedStrategies +{ + /// + /// Builds a strategy that: + /// + /// Prepends SMS for critical messages (if viable), otherwise + /// Uses the user's preferred channel order (deduplicated, preserving first occurrence) + /// Evaluates per-channel gates and executes the first viable channel + /// Falls back to Email's send handler if nothing is viable + /// + /// + /// Identity/consent service. + /// Presence/DND service. + /// Per-channel rate limiter. + /// Preference service providing channel ordering. + /// Email sender. + /// SMS sender. + /// Push sender. + /// IM sender. + /// An that returns . + /// + /// + /// Default fallback is Email's send handler. This fallback is invoked even if the email gate would fail, + /// by design, to guarantee a final delivery attempt. + /// + /// + /// The returned strategy is immutable and safe for concurrent use, given thread-safe dependencies. + /// + /// + /// + /// + /// var strategy = ComposedStrategies.BuildPreferenceAware(id, presence, rate, prefs, email, sms, push, im); + /// var result = await strategy.ExecuteAsync(new SendContext(userId, "Hello", false), CancellationToken.None); + /// + /// + public static AsyncStrategy BuildPreferenceAware( + IIdentityService id, + IPresenceService presence, + IRateLimiter rate, + IPreferenceService prefs, + IEmailSender email, + ISmsSender sms, + IPushSender push, + IImSender im) + { + // Build policies once; methods inside factory are instance members (no per-call closures) + var factory = new ChannelPolicyFactory(id, presence, rate, email, sms, push, im); + var policies = factory.CreateAll(); + + return AsyncStrategy.Create() + .When(IsCritical) + .Then(ExecuteCritical) + .Default(ExecuteByPrefs) + .Build(); + + // Top-level predicates/executors (named local methods; clear and testable) + static ValueTask IsCritical(SendContext c, CancellationToken _) => new(c.IsCritical); + + ValueTask ExecuteCritical(SendContext c, CancellationToken t) + { + static ValueTask CriticalOrder(SendContext _) => + PrependIfMissing(Channel.Sms, []); + + // Default fallback is Email policy's send handler. + var def = policies[Channel.Email].Send; + return ExecuteOrderedAsync(c, t, CriticalOrder, policies, def); + } + + ValueTask PreferredOrder(SendContext c, CancellationToken t) => + prefs.GetPreferredOrderAsync(c.UserId, t); + + ValueTask ExecuteByPrefs(SendContext c, CancellationToken t) + { + var def = policies[Channel.Email].Send; + return ExecuteOrderedAsync(c, t, ctx => PreferredOrder(ctx, t), policies, def); + } + } + + // Compose an ordered strategy at runtime, based on gates; no switches. + private static async ValueTask ExecuteOrderedAsync( + SendContext ctx, + CancellationToken ct, + Func> orderFactory, + IReadOnlyDictionary policies, + AsyncStrategy.Handler @default) + { + var order = await orderFactory(ctx).ConfigureAwait(false); + + // De-dupe while preserving order + var distinct = new List(order.Length); + distinct.AddRange(order.Distinct()); + + var b = AsyncStrategy.Create(); + b = distinct + .Select(ch => policies[ch]) + .Aggregate(b, (current, policy) + => current + .When(policy.Gate.ExecuteAsync) // method group on the instance gate + .Then(policy.Send)); + + var strat = b.Default(@default).Build(); + return await strat.ExecuteAsync(ctx, ct).ConfigureAwait(false); + } + + private static ValueTask PrependIfMissing(Channel ch, Channel[] order) + { + if (order.Length != 0 && order[0] == ch) + return new ValueTask(order); + + var arr = new Channel[order.Length + 1]; + arr[0] = ch; + Array.Copy(order, 0, arr, 1, order.Length); + return new ValueTask(arr); + } + + /// + /// Applies a continuation to a without additional allocations + /// when already completed successfully. + /// + /// The task to continue. + /// Continuation mapping bool → bool. + /// A continued . + /// + /// Used by the DND inversion check to avoid extra await/lambda allocations + /// when the source is already completed. + /// + internal static ValueTask Continue(this ValueTask t, Func f) => + t.IsCompletedSuccessfully ? new ValueTask(f(t.Result)) : Awaited(t, f); + + private static async ValueTask Awaited(ValueTask t, Func f) => + f(await t.ConfigureAwait(false)); +} diff --git a/src/PatternKit.Examples/packages.lock.json b/src/PatternKit.Examples/packages.lock.json index f8b1ae3..a70dee8 100644 --- a/src/PatternKit.Examples/packages.lock.json +++ b/src/PatternKit.Examples/packages.lock.json @@ -1,9 +1,142 @@ { "version": 1, "dependencies": { + "net8.0": { + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Direct", + "requested": "[9.0.9, )", + "resolved": "9.0.9", + "contentHash": "p5RKAY9POvs3axwA/AQRuJeM8AHuE8h4qbP1NxQeGm0ep46aXz1oCLAp/oOYxX1GsjStgdhHrN3XXLLXr0+b3w==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Direct", + "requested": "[9.0.9, )", + "resolved": "9.0.9", + "contentHash": "6SIp/6Bngk4jm2W36JekZbiIbFPdE/eMUtrJEqIqHGpd1zar3jvgnwxnpWQfzUiGrkyY8q8s6V82zkkEZozghA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.9" + } + }, + "Microsoft.Extensions.Options": { + "type": "Direct", + "requested": "[9.0.9, )", + "resolved": "9.0.9", + "contentHash": "loxGGHE1FC2AefwPHzrjPq7X92LQm64qnU/whKfo6oWaceewPUVYQJBJs3S3E2qlWwnCpeZ+dGCPTX+5dgVAuQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Direct", + "requested": "[9.0.9, )", + "resolved": "9.0.9", + "contentHash": "n4DCdnn2qs6V5U06Sx62FySEAZsJiJJgOzrPHDh9hPK7c2W8hEabC76F3Re3tGPjpiKa02RvB6FxZyxo8iICzg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.9", + "Microsoft.Extensions.Configuration.Binder": "9.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Options": "9.0.9", + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Options.DataAnnotations": { + "type": "Direct", + "requested": "[9.0.9, )", + "resolved": "9.0.9", + "contentHash": "Al+1FXnKKFygTXz0Zsa1+jYEPvsx5dKavlJxMXjRbrL6lmBhQZsVMhjuNB5lWvdRhdoxt5y/Q3v5kbLZLsXWdA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Options": "9.0.9" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "/hymojfWbE9AlDOa0mczR44m00Jj+T3+HZO0ZnVTI032fVycI0ZbNOVFP6kqZMcXiLSYXzR2ilcwaRi6dzeGyA==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "z4pyMePOrl733ltTowbN565PxBw1oAr8IHmIXNDiDqd22nFpYltX9KhrNC/qBWAG1/Zx5MHX+cOYhWJQYCO/iw==" + }, + "patternkit.core": { + "type": "Project" + }, + "patternkit.generators.abstractions": { + "type": "Project" + } + }, "net9.0": { + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Direct", + "requested": "[9.0.9, )", + "resolved": "9.0.9", + "contentHash": "p5RKAY9POvs3axwA/AQRuJeM8AHuE8h4qbP1NxQeGm0ep46aXz1oCLAp/oOYxX1GsjStgdhHrN3XXLLXr0+b3w==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Direct", + "requested": "[9.0.9, )", + "resolved": "9.0.9", + "contentHash": "6SIp/6Bngk4jm2W36JekZbiIbFPdE/eMUtrJEqIqHGpd1zar3jvgnwxnpWQfzUiGrkyY8q8s6V82zkkEZozghA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.9" + } + }, + "Microsoft.Extensions.Options": { + "type": "Direct", + "requested": "[9.0.9, )", + "resolved": "9.0.9", + "contentHash": "loxGGHE1FC2AefwPHzrjPq7X92LQm64qnU/whKfo6oWaceewPUVYQJBJs3S3E2qlWwnCpeZ+dGCPTX+5dgVAuQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Direct", + "requested": "[9.0.9, )", + "resolved": "9.0.9", + "contentHash": "n4DCdnn2qs6V5U06Sx62FySEAZsJiJJgOzrPHDh9hPK7c2W8hEabC76F3Re3tGPjpiKa02RvB6FxZyxo8iICzg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.9", + "Microsoft.Extensions.Configuration.Binder": "9.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Options": "9.0.9", + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Options.DataAnnotations": { + "type": "Direct", + "requested": "[9.0.9, )", + "resolved": "9.0.9", + "contentHash": "Al+1FXnKKFygTXz0Zsa1+jYEPvsx5dKavlJxMXjRbrL6lmBhQZsVMhjuNB5lWvdRhdoxt5y/Q3v5kbLZLsXWdA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Options": "9.0.9" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "/hymojfWbE9AlDOa0mczR44m00Jj+T3+HZO0ZnVTI032fVycI0ZbNOVFP6kqZMcXiLSYXzR2ilcwaRi6dzeGyA==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "z4pyMePOrl733ltTowbN565PxBw1oAr8IHmIXNDiDqd22nFpYltX9KhrNC/qBWAG1/Zx5MHX+cOYhWJQYCO/iw==" + }, "patternkit.core": { "type": "Project" + }, + "patternkit.generators.abstractions": { + "type": "Project" } } } diff --git a/src/PatternKit.Generators.Abstractions/GenerateStrategyAttribute.cs b/src/PatternKit.Generators.Abstractions/GenerateStrategyAttribute.cs new file mode 100644 index 0000000..0e22a71 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/GenerateStrategyAttribute.cs @@ -0,0 +1,32 @@ +namespace PatternKit.Generators; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] +public sealed class GenerateStrategyAttribute : Attribute +{ + public string Name { get; } + public Type InType { get; } + public Type? OutType { get; } + public StrategyKind Kind { get; } + + public GenerateStrategyAttribute(string name, Type inType, StrategyKind kind) + { + Name = name; + InType = inType; + Kind = kind; + } + + public GenerateStrategyAttribute(string name, Type inType, Type outType, StrategyKind kind) + { + Name = name; + InType = inType; + OutType = outType; + Kind = kind; + } +} + +public enum StrategyKind +{ + Action, + Result, + Try +} \ No newline at end of file diff --git a/src/PatternKit.Generators.Abstractions/PatternKit.Generators.Abstractions.csproj b/src/PatternKit.Generators.Abstractions/PatternKit.Generators.Abstractions.csproj new file mode 100644 index 0000000..0103330 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/PatternKit.Generators.Abstractions.csproj @@ -0,0 +1,8 @@ + + + + netstandard2.0 + PatternKit.Generators + + + diff --git a/src/PatternKit.Generators.Abstractions/Properties/AssemblyCoverage.cs b/src/PatternKit.Generators.Abstractions/Properties/AssemblyCoverage.cs new file mode 100644 index 0000000..598a1a7 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Properties/AssemblyCoverage.cs @@ -0,0 +1,4 @@ +#if NETSTANDARD2_1 +// Exclude the entire assembly from coverage when built for netstandard2.1 +[assembly: System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +#endif \ No newline at end of file diff --git a/src/PatternKit.Generators.Abstractions/packages.lock.json b/src/PatternKit.Generators.Abstractions/packages.lock.json new file mode 100644 index 0000000..b7e843e --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/packages.lock.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.0": { + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + } + } + } +} \ No newline at end of file diff --git a/src/PatternKit.Generators/PatternKit.Generators.csproj b/src/PatternKit.Generators/PatternKit.Generators.csproj new file mode 100644 index 0000000..318715b --- /dev/null +++ b/src/PatternKit.Generators/PatternKit.Generators.csproj @@ -0,0 +1,21 @@ + + + + netstandard2.0 + true + true + false + true + + + + + + + + + + + + diff --git a/src/PatternKit.Generators/Properties/AssemblyCoverage.cs b/src/PatternKit.Generators/Properties/AssemblyCoverage.cs new file mode 100644 index 0000000..598a1a7 --- /dev/null +++ b/src/PatternKit.Generators/Properties/AssemblyCoverage.cs @@ -0,0 +1,4 @@ +#if NETSTANDARD2_1 +// Exclude the entire assembly from coverage when built for netstandard2.1 +[assembly: System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +#endif \ No newline at end of file diff --git a/src/PatternKit.Generators/StrategyGenerator.cs b/src/PatternKit.Generators/StrategyGenerator.cs new file mode 100644 index 0000000..a8ec8a8 --- /dev/null +++ b/src/PatternKit.Generators/StrategyGenerator.cs @@ -0,0 +1,299 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace PatternKit.Generators; + +[Generator] +public sealed class StrategyGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext ctx) + { + + + + // 2) Find every *occurrence* of our attribute. + var occurrences = ctx.SyntaxProvider.ForAttributeWithMetadataName( + fullyQualifiedMetadataName: "PatternKit.Generators.GenerateStrategyAttribute", + predicate: static (node, _) => node is ClassDeclarationSyntax, + transform: static (gasc, _) => gasc // one per attribute usage + ); + + // 3) Generate one class for each attribute. + ctx.RegisterSourceOutput(occurrences.Collect(), static (spc, items) => + { + foreach (var occ in items) + { + // Each 'occ' should correspond to a single attribute instance. + // But GeneratorAttributeSyntaxContext.Attributes may contain several attrs on symbol, + // so pick the one matching the syntax we were triggered for. + var attr = GetExactAttributeForOccurrence(occ); + if (attr is null) + { + Report(spc, "PKGEN001", "Unable to find matching attribute instance.", DiagnosticSeverity.Warning, occ.TargetNode.GetLocation()); + continue; + } + + if (!TryRead(attr, out var name, out var inType, out var outType, out var kind, out var error)) + { + Report(spc, "PKGEN002", error ?? "Invalid attribute arguments.", DiagnosticSeverity.Warning, occ.TargetNode.GetLocation()); + continue; + } + + var ns = occ.TargetSymbol.ContainingNamespace.IsGlobalNamespace + ? "GlobalNamespace" + : occ.TargetSymbol.ContainingNamespace.ToDisplayString(); + + var src = kind switch + { + 0 => EmitAction(ns, name, inType), + 1 => EmitResult(ns, name, inType, outType!), + 2 => EmitTry(ns, name, inType, outType!), + _ => null + }; + + if (!string.IsNullOrEmpty(src)) + { + spc.AddSource($"{Sanitize(name)}.g.cs", src!); + } + } + }); + } + + private static AttributeData? GetExactAttributeForOccurrence(GeneratorAttributeSyntaxContext occ) + { + // We were triggered by a specific AttributeSyntax; match it back to AttributeData + var targetAttrSyntax = (AttributeSyntax)occ.Attributes[0].ApplicationSyntaxReference!.GetSyntax(); + foreach (var a in occ.Attributes) + { + var syn = a.ApplicationSyntaxReference?.GetSyntax(); + if (syn is AttributeSyntax attrSyn && attrSyn == targetAttrSyntax) + return a; + } + + return null; + } + + private static bool TryRead( + AttributeData a, + out string name, + out string inType, + out string? outType, + out int kindValue, + out string? error) + { + name = default!; + inType = default!; + outType = null; + kindValue = default; + error = null; + + try + { + if (a.ConstructorArguments.Length == 3) + { + // (string name, Type inType, StrategyKind kind) + name = (string)a.ConstructorArguments[0].Value!; + inType = ((INamedTypeSymbol)a.ConstructorArguments[1].Value!).ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + kindValue = (int)a.ConstructorArguments[2].Value!; + } + else if (a.ConstructorArguments.Length == 4) + { + // (string name, Type inType, Type outType, StrategyKind kind) + name = (string)a.ConstructorArguments[0].Value!; + inType = ((INamedTypeSymbol)a.ConstructorArguments[1].Value!).ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + outType = ((INamedTypeSymbol)a.ConstructorArguments[2].Value!).ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + kindValue = (int)a.ConstructorArguments[3].Value!; + } + else + { + error = "Unexpected constructor arity for GenerateStrategyAttribute."; + return false; + } + + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } + + private static string EmitAction(string ns, string name, string inType) + { + return $$""" + #nullable enable + // + using PatternKit.Creational.Builder; + + namespace {{ns}}; + + public sealed partial class {{name}} + { + public delegate bool Predicate(in {{inType}} input); + public delegate void ActionHandler(in {{inType}} input); + + private readonly Predicate[] _preds; + private readonly ActionHandler[] _actions; + private readonly bool _hasDefault; + private readonly ActionHandler _default; + + private static ActionHandler Noop => static (in {{inType}} _) => { }; + + private {{name}}(Predicate[] p, ActionHandler[] a, bool hasDef, ActionHandler def) + => (_preds, _actions, _hasDefault, _default) = (p, a, hasDef, def); + + public void Execute(in {{inType}} input) + { + var p = _preds; + for (int i = 0; i < p.Length; i++) + if (p[i](in input)) { _actions[i](in input); return; } + if (_hasDefault) { _default(in input); return; } + PatternKit.Common.Throw.NoStrategyMatched(); + } + + public bool TryExecute(in {{inType}} input) + { + var p = _preds; + for (int i = 0; i < p.Length; i++) + if (p[i](in input)) { _actions[i](in input); return true; } + if (_hasDefault) { _default(in input); return true; } + return false; + } + + public sealed class Builder + { + private readonly BranchBuilder _core = BranchBuilder.Create(); + public WhenBuilder When(Predicate pred) => new(this, pred); + public Builder Default(ActionHandler action) { _core.Default(action); return this; } + public {{name}} Build() => _core.Build(Noop, static (p, a, hasDef, def) => new {{name}}(p, a, hasDef, def)); + + public sealed class WhenBuilder + { + private readonly Builder _owner; private readonly Predicate _pred; + internal WhenBuilder(Builder owner, Predicate pred) { _owner = owner; _pred = pred; } + public Builder Then(ActionHandler action) { _owner._core.Add(_pred, action); return _owner; } + } + } + + public static Builder Create() => new(); + } + """; + } + + private static string EmitResult(string ns, string name, string inType, string outType) + { + return $$""" + #nullable enable + // + using PatternKit.Creational.Builder; + + namespace {{ns}}; + + public sealed partial class {{name}} + { + public delegate bool Predicate(in {{inType}} input); + public delegate {{outType}} Handler(in {{inType}} input); + + private readonly Predicate[] _preds; + private readonly Handler[] _handlers; + private readonly bool _hasDefault; + private readonly Handler _default; + + private static Handler DefaultRes => static (in {{inType}} _) => default!; + + private {{name}}(Predicate[] p, Handler[] h, bool hasDef, Handler def) + => (_preds, _handlers, _hasDefault, _default) = (p, h, hasDef, def); + + public {{outType}} Execute(in {{inType}} input) + { + var p = _preds; + for (int i = 0; i < p.Length; i++) + if (p[i](in input)) return _handlers[i](in input); + return _hasDefault ? _default(in input) : PatternKit.Common.Throw.NoStrategyMatched<{{outType}}>(); + } + + public sealed class Builder + { + private readonly BranchBuilder _core = BranchBuilder.Create(); + public WhenBuilder When(Predicate pred) => new(this, pred); + public Builder Default(Handler handler) { _core.Default(handler); return this; } + public {{name}} Build() => _core.Build(DefaultRes, static (p, h, hasDef, def) => new {{name}}(p, h, hasDef, def)); + + public sealed class WhenBuilder + { + private readonly Builder _owner; private readonly Predicate _pred; + internal WhenBuilder(Builder owner, Predicate pred) { _owner = owner; _pred = pred; } + public Builder Then(Handler handler) { _owner._core.Add(_pred, handler); return _owner; } + } + } + + public static Builder Create() => new(); + } + """; + } + + private static string EmitTry(string ns, string name, string inType, string outType) + { + return $$""" + #nullable enable + // + using PatternKit.Creational.Builder; + + namespace {{ns}}; + + public sealed partial class {{name}} + { + public delegate bool TryHandler(in {{inType}} input, out {{outType}}? result); + + private readonly TryHandler[] _handlers; + private {{name}}(TryHandler[] handlers) => _handlers = handlers; + + public bool Execute(in {{inType}} input, out {{outType}}? result) + { + foreach (var h in _handlers) + if (h(in input, out result)) + return true; + result = default; + return false; + } + + public sealed class Builder + { + private readonly PatternKit.Creational.Builder.ChainBuilder _core + = PatternKit.Creational.Builder.ChainBuilder.Create(); + + public Builder Always(TryHandler handler) { _core.Add(handler); return this; } + public WhenBuilder When(bool condition) => new(this, condition); + public Builder Finally(TryHandler handler) { _core.Add(handler); return this; } + public Builder Or => this; + + public {{name}} Build() => _core.Build(static hs => new {{name}}(hs)); + + public readonly struct WhenBuilder + { + private readonly Builder _owner; private readonly bool _cond; + internal WhenBuilder(Builder owner, bool cond) { _owner = owner; _cond = cond; } + public WhenBuilder Add(TryHandler handler) { _owner._core.AddIf(_cond, handler); return this; } + public WhenBuilder And(TryHandler handler) => Add(handler); + public Builder End => _owner; + public Builder Or => _owner; + public Builder Finally(TryHandler handler) => _owner.Finally(handler); + } + } + + public static Builder Create() => new(); + } + """; + } + + private static string Sanitize(string s) + => s.Replace('<', '_').Replace('>', '_').Replace('.', '_'); + + private static void Report(SourceProductionContext spc, string id, string message, DiagnosticSeverity severity, Location? loc) + { + var descriptor = new DiagnosticDescriptor(id, id, message, "PatternKit.Generators", severity, isEnabledByDefault: true); + spc.ReportDiagnostic(Diagnostic.Create(descriptor, loc)); + } + +} \ No newline at end of file diff --git a/src/PatternKit.Generators/packages.lock.json b/src/PatternKit.Generators/packages.lock.json new file mode 100644 index 0000000..2bf50a3 --- /dev/null +++ b/src/PatternKit.Generators/packages.lock.json @@ -0,0 +1,121 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.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.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" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.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" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "QhkXUl2gNrQtvPmtBTQHb0YsUrDiDQ2QS09YbtTTiSjGcf7NBqtYbrG/BE06zcBPCKEwQGzIv13IVdXNOSub2w==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "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" + } + } + } + } +} \ No newline at end of file diff --git a/test/PatternKit.Examples.Tests/ApiGateway/ApiGatewayTests.cs b/test/PatternKit.Examples.Tests/ApiGateway/ApiGatewayTests.cs new file mode 100644 index 0000000..e7fbe1e --- /dev/null +++ b/test/PatternKit.Examples.Tests/ApiGateway/ApiGatewayTests.cs @@ -0,0 +1,168 @@ +using PatternKit.Examples.ApiGateway; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.ApiGateway; + +[Feature("Mini API Gateway routing & middleware")] +public sealed class ApiGatewayTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + private static IReadOnlyDictionary Headers( + string? accept = "application/json", + string? auth = null, + string? requestId = null) + { + var d = new Dictionary(); + if (!string.IsNullOrEmpty(accept)) d["Accept"] = accept; + if (!string.IsNullOrEmpty(auth)) d["Authorization"] = auth; + if (!string.IsNullOrEmpty(requestId)) d["X-Request-Id"] = requestId; + return d; + } + + private static MiniRouter DefaultRouter() + => MiniRouter.Create() + // middleware: first match wins (side effects only — not asserted here) + .Use( + static (in r) => r.Headers.ContainsKey("X-Request-Id"), + static (in r) => Console.WriteLine($"reqid={r.Headers["X-Request-Id"]}")) + .Use( + static (in r) => r.Path.StartsWith("/admin", StringComparison.Ordinal) && + !r.Headers.ContainsKey("Authorization"), + static (in _) => Console.WriteLine("Denied: missing Authorization")) + // routes + .Map( + static (in r) => r is { Method: "GET", Path: "/health" }, + static (in _) => Responses.Text(200, "OK")) + .Map( + static (in r) => r.Method == "GET" && r.Path.StartsWith("/users/", StringComparison.Ordinal), + static (in r) => + { + var idStr = r.Path["/users/".Length..]; + return int.TryParse(idStr, out var id) + ? Responses.Json(200, $"{{\"id\":{id},\"name\":\"user{id}\"}}") + : Responses.Text(404, "User not found"); + }) + .Map( + static (in r) => r is { Method: "POST", Path: "/users" }, + static (in _) => Responses.Json(201, "{\"ok\":true}")) + .Map( + static (in r) => r.Path.StartsWith("/admin", StringComparison.Ordinal) && + !r.Headers.ContainsKey("Authorization"), + static (in _) => Responses.Unauthorized()) + .NotFound(static (in _) => Responses.NotFound()) + .Build(); + + // A router with a route that returns an empty content-type to exercise negotiation. + private static MiniRouter NegotiatingRouter() + => MiniRouter.Create() + .Map( + static (in r) => r is { Method: "GET", Path: "/neg" }, + static (in _) => new Response(200, "", "ok")) + .NotFound(static (in _) => new Response(404, "", "nope")) + .Build(); + + // --- scenarios ----------------------------------------------------------- + + [Scenario("GET /health returns 200 text/plain 'OK'")] + [Fact] + public async Task Health_Check() + { + await Given("a default router", DefaultRouter) + .When("GET /health", r => r.Handle(new Request("GET", "/health", Headers()))) + .Then("status is 200", res => res.StatusCode == 200) + .And("body is 'OK'", res => res.Body == "OK") + .And("content-type is text/plain", res => res.ContentType.StartsWith("text/plain")) + .AssertPassed(); + } + + [Scenario("GET /users/42 -> 200 application/json with id")] + [Fact] + public async Task Users_Get_By_Id() + { + await Given("a default router", DefaultRouter) + .When("GET /users/42", r => r.Handle(new Request("GET", "/users/42", Headers()))) + .Then("status 200", res => res.StatusCode == 200) + .And("content-type json", res => res.ContentType.StartsWith("application/json")) + .And("body contains id 42", res => res.Body.Contains("\"id\":42")) + .AssertPassed(); + } + + [Scenario("GET /users/abc -> 404 text/plain 'User not found'")] + [Fact] + public async Task Users_Get_Invalid_Id() + { + await Given("a default router", DefaultRouter) + .When("GET /users/abc", r => r.Handle(new Request("GET", "/users/abc", Headers()))) + .Then("status 404", res => res.StatusCode == 404) + .And("text/plain", res => res.ContentType.StartsWith("text/plain")) + .And("body says 'User not found'", res => res.Body.Contains("User not found")) + .AssertPassed(); + } + + [Scenario("POST /users -> 201 application/json {\"ok\":true}")] + [Fact] + public async Task Users_Create() + { + await Given("a default router", DefaultRouter) + .When("POST /users", r => r.Handle(new Request("POST", "/users", Headers(), "{\"name\":\"Ada\"}"))) + .Then("status 201", res => res.StatusCode == 201) + .And("content-type json", res => res.ContentType.StartsWith("application/json")) + .And("body {\"ok\":true}", res => res.Body.Contains("\"ok\":true")) + .AssertPassed(); + } + + [Scenario("GET /admin without Authorization -> 401 Unauthorized")] + [Fact] + public async Task Admin_Requires_Authorization() + { + await Given("a default router", DefaultRouter) + .When("GET /admin/metrics without auth", r => r.Handle(new Request("GET", "/admin/metrics", Headers(auth: null)))) + .Then("status 401", res => res.StatusCode == 401) + .And("text/plain", res => res.ContentType.StartsWith("text/plain")) + .AssertPassed(); + } + + [Scenario("Content negotiation picks json or text when handler leaves content-type empty")] + [Fact] + public async Task Content_Negotiation_Works() + { + await Given("a negotiating router", NegotiatingRouter) + .When("GET /neg with Accept: application/json", r => r.Handle(new Request("GET", "/neg", Headers(accept: "application/json")))) + .Then("content-type is application/json", res => res.ContentType.StartsWith("application/json")) + .And("status 200", res => res.StatusCode == 200) + .And("body ok", res => res.Body == "ok") + .AssertPassed(); + + await Given("a negotiating router", NegotiatingRouter) + .When("GET /neg with Accept: text/plain", r => r.Handle(new Request("GET", "/neg", Headers(accept: "text/plain")))) + .Then("content-type is text/plain", res => res.ContentType.StartsWith("text/plain")) + .AssertPassed(); + + await Given("a negotiating router", NegotiatingRouter) + .When("GET /neg with unknown Accept", r => r.Handle(new Request("GET", "/neg", Headers(accept: "application/xml")))) + .Then("falls back to json", res => res.ContentType.StartsWith("application/json")) + .AssertPassed(); + } + + [Scenario("Middleware is first-match-wins (only the first matching action executes)")] + [Fact] + public async Task Middleware_FirstMatch_Wins() + { + var hits = new List(); + + MiniRouter Build() + => MiniRouter.Create() + .Use(static (in r) => r.Path.StartsWith("/a"), (in _) => hits.Add("A")) + .Use(static (in r) => r.Path.StartsWith("/a"), (in _) => hits.Add("B")) // also matches but should NOT run + .Map(static (in _) => true, static (in _) => Responses.Text(200, "ok")) + .NotFound(static (in _) => Responses.NotFound()) + .Build(); + + await Given("a router with two matching middleware branches", Build) + .When("GET /a", r => r.Handle(new Request("GET", "/a", Headers()))) + .Then("exactly one middleware ran", _ => hits.Count == 1) + .And("the first middleware ran", _ => hits[0] == "A") + .AssertPassed(); + } +} \ No newline at end of file diff --git a/test/PatternKit.Examples.Tests/ApiGateway/DemoTests.cs b/test/PatternKit.Examples.Tests/ApiGateway/DemoTests.cs new file mode 100644 index 0000000..32b2cd2 --- /dev/null +++ b/test/PatternKit.Examples.Tests/ApiGateway/DemoTests.cs @@ -0,0 +1,62 @@ +using PatternKit.Examples.ApiGateway; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.ApiGateway; + +[Feature("ApiGateway Demo.Run prints expected responses and middleware output")] +public sealed class DemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Demo.Run end-to-end console output")] + [Fact] + public async Task Demo_Run_Prints_Expected_Lines() + { + await Given("a function that runs Demo.Run and captures console", + () => (Func)(() => CaptureConsole(Demo.Run))) + .When("executing the demo", + run => run()) + .Then("prints 200 text/plain OK for /health", + text => text.Contains("200 text/plain") && text.Contains("OK")) + .And("prints 200 application/json with user payload for /users/42", + text => text.Contains("200 application/json") && + text.Contains("\"id\":42") && + text.Contains("\"name\":\"user42\"")) + .And("prints 404 text/plain 'User not found' for /users/abc", + text => text.Contains("404 text/plain") && + text.Contains("User not found")) + .And("logs 'Denied: missing Authorization' before the 401 for /admin/metrics", + text => + { + var denied = text.IndexOf("Denied: missing Authorization", StringComparison.Ordinal); + var unauthorized = text.IndexOf("401 text/plain", StringComparison.Ordinal); + return denied >= 0 && unauthorized >= 0 && denied < unauthorized; + }) + .And("prints 201 application/json for POST /users", + text => text.Contains("201 application/json") && + text.Contains("\"ok\":true")) + .And("prints 404 text/plain Not Found for /nope", + text => text.Contains("404 text/plain") && + text.Contains("Not Found")) + .And("does not print a reqid line since X-Request-Id header wasn't set", + text => !text.Contains("reqid=")) + .AssertPassed(); + } + + // --- helper to capture console output for the duration of an action --- + private static string CaptureConsole(Action act) + { + var sw = new StringWriter(); + var prev = Console.Out; + try + { + Console.SetOut(sw); + act(); + return sw.ToString(); + } + finally + { + Console.SetOut(prev); + } + } +} \ No newline at end of file diff --git a/test/PatternKit.Examples.Tests/Chain/AuthLoggingDemoTests.cs b/test/PatternKit.Examples.Tests/Chain/AuthLoggingDemoTests.cs new file mode 100644 index 0000000..2c86bbd --- /dev/null +++ b/test/PatternKit.Examples.Tests/Chain/AuthLoggingDemoTests.cs @@ -0,0 +1,118 @@ +using PatternKit.Behavioral.Chain; +using PatternKit.Examples.Chain; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.Chain; + +[Feature("Auth & Logging demo (ActionChain)")] +public sealed class AuthLoggingDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + // --- Helpers ------------------------------------------------------------ + + private static IReadOnlyDictionary H( + string? requestId = null, + string? auth = null) + { + var d = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrWhiteSpace(requestId)) d["X-Request-Id"] = requestId!; + if (!string.IsNullOrWhiteSpace(auth)) d["Authorization"] = auth!; + return d; + } + + /// + /// Recreates the demo chain so we can drive it with custom inputs. + /// + private static (ActionChain Chain, List Log) BuildChain() + { + var log = new List(); + + var chain = ActionChain.Create() + // request id (continue) + .When(static (in r) => r.Headers.ContainsKey("X-Request-Id")) + .ThenContinue(r => log.Add($"reqid={r.Headers["X-Request-Id"]}")) + // admin requires auth (stop) + .When(static (in r) => r.Path.StartsWith("/admin", StringComparison.Ordinal) + && !r.Headers.ContainsKey("Authorization")) + .ThenStop(_ => log.Add("deny: missing auth")) + // finally always logs method/path + .Finally((in r, next) => + { + log.Add($"{r.Method} {r.Path}"); + next(r); // terminal 'next' is a no-op + }) + .Build(); + + return (chain, log); + } + + // --- Scenarios ---------------------------------------------------------- + + [Scenario("AuthLoggingDemo.Run returns the expected three log lines in order")] + [Fact] + public Task Demo_Run_Smoke() + => Given("the demo Run() helper", () => (Func>)AuthLoggingDemo.Run) + .When("running it", run => run()) + .Then("logs method/path for /health first", log => log.ElementAtOrDefault(0) == "GET /health") + .And("then logs a deny for missing auth on /admin", log => log.ElementAtOrDefault(1) == "deny: missing auth") + .And("stops after deny (no /admin method/path)", log => log.Count == 2 && !log.Any(l => l.Contains("/admin"))) + .AssertPassed(); + + [Scenario("X-Request-Id is logged before method/path")] + [Fact] + public Task RequestId_Is_Logged_Then_MethodPath() + => Given("a fresh chain+log", BuildChain) + .When("executing a GET /users with X-Request-Id", t => + { + var (chain, log) = t; + chain.Execute(new HttpRequest("GET", "/users", H(requestId: "abc123"))); + return log; + }) + .Then("first line is reqid", log => log[0] == "reqid=abc123") + .And("second line is method/path", log => log[1] == "GET /users") + .And("only these two lines exist", log => log.Count == 2) + .AssertPassed(); + + [Scenario("Admin without Authorization -> deny and still logs method/path (Finally runs)")] + [Fact] + public Task Admin_MissingAuth_Denies_And_Logs_Path() + => Given("a fresh chain+log", BuildChain) + .When("executing GET /admin/stats without auth", t => + { + var (chain, log) = t; + chain.Execute(new HttpRequest("GET", "/admin/stats", H())); + return log; + }) + .Then("first line is deny", log => log.ElementAtOrDefault(0) == "deny: missing auth") + .And("no method/path is logged after stop", log => log.Count == 1) + .AssertPassed(); + + [Scenario("Admin with Authorization -> no deny, just method/path")] + [Fact] + public Task Admin_WithAuth_Allows_And_Logs_Path() + => Given("a fresh chain+log", BuildChain) + .When("executing GET /admin/metrics with bearer token", t => + { + var (chain, log) = t; + chain.Execute(new HttpRequest("GET", "/admin/metrics", H(auth: "Bearer token"))); + return log; + }) + .Then("single line is method/path", log => log.SequenceEqual(new[] { "GET /admin/metrics" })) + .AssertPassed(); + + [Scenario("Order with both: X-Request-Id then deny then method/path")] + [Fact] + public Task RequestId_Then_Deny() + => Given("a fresh chain+log", BuildChain) + .When("executing GET /admin/x with X-Request-Id and no auth", t => + { + var (chain, log) = t; + chain.Execute(new HttpRequest("GET", "/admin/x", H(requestId: "rid-7"))); + return log; + }) + .Then("reqid is logged first", log => log.ElementAtOrDefault(0) == "reqid=rid-7") + .And("deny next", log => log.ElementAtOrDefault(1) == "deny: missing auth") + .And("stops after deny (no method/path)", log => log.Count == 2 && !log.Any(s => s.StartsWith("GET "))) + .AssertPassed(); +} \ No newline at end of file diff --git a/test/PatternKit.Examples.Tests/Chain/MediatedTransactionPipelineDemoTests.cs b/test/PatternKit.Examples.Tests/Chain/MediatedTransactionPipelineDemoTests.cs new file mode 100644 index 0000000..2bdf747 --- /dev/null +++ b/test/PatternKit.Examples.Tests/Chain/MediatedTransactionPipelineDemoTests.cs @@ -0,0 +1,50 @@ +using PatternKit.Examples.Chain; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.Chain; + + +[Collection("Culture")] +[Feature("Mediated Transaction pipeline – cash + loyalty + cigarettes")] +public sealed class MediatedTransactionPipelineDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + private static TransactionContext ArrangeCtx() + => new() + { + Customer = new Customer(LoyaltyId: "LOYAL-123", AgeYears: 25), + Tender = new Tender(PaymentKind.Cash, CashGiven: 50m), + Items = + [ + new LineItem("CIGS", 10.96m, Qty: 1, AgeRestricted: true, BundleKey: "CIGS"), + new LineItem("CIGS", 10.97m, Qty: 1, AgeRestricted: true, BundleKey: "CIGS"), + ] + }; + + [Scenario("Cash customer with loyalty buys 2 cigarettes -> $20 total, $30 change, success")] + [Fact] + public async Task Cash_Cigs_Loyalty_EndToEnd() + { + await Given("a cash customer buying 2 age-restricted items with loyalty", ArrangeCtx) + .When("the mediated pipeline runs", MediatedTransactionPipelineDemo.Run) + .Then("preauth passes", r => r.Ctx.Log.Contains("preauth: ok")) + + // state-based (culture-proof) checks + .And("subtotal is 21.93", r => r.Ctx.Subtotal == 21.93m) + .And("total discounts are 3.54", r => r.Ctx.DiscountTotal == 3.54m) + .And("tax is 1.61", r => r.Ctx.TaxTotal == 1.61m) + .And("grand total is 20.00", r => r.Ctx.GrandTotal == 20.00m) + .And("cash change due is 30.00", r => r.Ctx.CashChange == 30.00m) + + // verify that each discount *rule* fired (without brittle currency text) + .And("cash discount applied", r => r.Ctx.Log.Any(s => s.StartsWith("discount: cash 2% off"))) + .And("loyalty discount applied", r => r.Ctx.Log.Any(s => s.StartsWith("discount: loyalty "))) + .And("bundle discount applied", r => r.Ctx.Log.Any(s => s.StartsWith("discount: bundle deal"))) + .And("tax logged", r => r.Ctx.Log.Any(s => s.StartsWith("tax:"))) + .And("total logged", r => r.Ctx.Log.Any(s => s.StartsWith("total: "))) + .And("transaction succeeds with 'paid' code", r => r.Result is { Ok: true, Code: "paid" }) + .And("pipeline logs completion", r => r.Ctx.Log.Contains("done.")) + .AssertPassed(); + } +} \ No newline at end of file diff --git a/test/PatternKit.Examples.Tests/Chain/NickelRoundingTests.cs b/test/PatternKit.Examples.Tests/Chain/NickelRoundingTests.cs new file mode 100644 index 0000000..f183ab7 --- /dev/null +++ b/test/PatternKit.Examples.Tests/Chain/NickelRoundingTests.cs @@ -0,0 +1,94 @@ +using PatternKit.Examples.Chain; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.Chain; + +[Feature("Nickel rounding")] +[Collection("Culture")] +public sealed class NickelRoundingTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Total 22.97 subtotal; first tender cash triggers 2% discount; then card pays remainder; no nickel rounding")] + [Fact] + public async Task Cash_Then_Card_No_Nickel_Round_With_Cash_Discount() + { + var ctx = new TransactionContext + { + Customer = new Customer(LoyaltyId: null, AgeYears: 30), + Items = [new("SKU-1", 22.97m)], // subtotal 22.97 + Tenders = + [ + new(PaymentKind.Cash, CashGiven: 20m), // first-tender cash => 2% discount applies + new(PaymentKind.Card, AuthType: CardAuthType.Chip, Vendor: CardVendor.Visa) + ] + }; + + await Given("a 22.97 subtotal basket with two tenders (20 cash, remainder card)", () => ctx) + .When("the mediated pipeline runs", c => MediatedTransactionPipelineDemo.Run(c).Ctx) + // totals + .Then("preauth passes", c => c.Result is null || c.Result.Value.Ok) + .And("subtotal is $22.97", c => c.Subtotal == 22.97m) + .And("cash 2% discount applied ($0.46)", c => + c.DiscountTotal == 0.46m && c.Log.Any(l => l.Contains("cash 2% off"))) + .And("tax is $1.97 (8.75% on 22.51)", c => c.TaxTotal == 1.97m) + .And("no nickel rounding applied", c => c.RoundingDelta == 0m) + .And("grand total is $24.48", c => c.GrandTotal == 24.48m) + // tendering + .And("cash applied is $20.00", c => c.AmountPaid >= 20m && c.Log.Any(l => l.Contains("paid: cash $20.00"))) + .And("no cash change is due", c => c.CashChange is null or 0m) + .And("card captured the remainder $4.48", c => + c.Log.Any(l => l.Contains("auth: captured via Visa $4.48")) || + c.Log.Any(l => l.Contains("VisaNet: captured $4.48"))) + .And("amount paid equals total", c => c.AmountPaid == 24.48m && c.RemainderDue == 0m) + // result & rounding log + .And("transaction succeeds", c => c.Result?.Ok == true) + .And("rounding logged as none", c => c.Log.Any(l => l.Contains("round: none"))) + .AssertPassed(); + } + + [Scenario("Subtotal 22.97; ROUND:NICKEL present; cash-only; nickel rounds 24.48 -> 24.50")] + [Fact] + public async Task CashOnly_Nickel_Rounds_Up_To_Nearest_0_05() + { + // Math: + // Subtotal = 22.97 + // Cash 2% discount = 0.46 => taxable = 22.51 + // Tax 8.75% on 22.51 = 1.97 + // Pre-round total = 22.97 - 0.46 + 1.97 = 24.48 + // Nickel rounding (cash-only) => +0.02 -> 24.50 + var ctx = new TransactionContext + { + Customer = new Customer(LoyaltyId: null, AgeYears: 30), + Items = + [ + new("SKU-1", 22.97m), + new("ROUND:NICKEL", 0m) // flag to enable cash-only nickel rounding + ], + Tenders = + [ + new(PaymentKind.Cash, CashGiven: 24.50m) // exact cash after rounding + ] + }; + + await Given("a 22.97 subtotal basket with ROUND:NICKEL and cash-only tender", () => ctx) + .When("the mediated pipeline runs", c => MediatedTransactionPipelineDemo.Run(c).Ctx) + // totals before rounding + .Then("preauth passes", c => c.Result is null || c.Result.Value.Ok) + .And("subtotal is $22.97", c => c.Subtotal == 22.97m) + .And("cash 2% discount applied ($0.46)", c => + c.DiscountTotal == 0.46m && c.Log.Any(l => l.Contains("cash 2% off"))) + .And("tax is $1.97", c => c.TaxTotal == 1.97m) + // rounding + .And("nickel rounding applied +$0.02", c => + c.RoundingDelta == 0.02m && + c.Log.Any(l => l.Contains("round: nickel (cash-only) +$0.02"))) + .And("grand total is $24.50", c => c.GrandTotal == 24.50m) + // tendering + .And("cash applied is $24.50", c => c.AmountPaid == 24.50m && c.RemainderDue == 0m) + .And("no cash change is due", c => c.CashChange is null or 0m) + // result + .And("transaction succeeds", c => c.Result?.Ok == true) + .AssertPassed(); + } +} \ No newline at end of file diff --git a/test/PatternKit.Examples.Tests/Chain/TransactionPipelineDemoTests.cs b/test/PatternKit.Examples.Tests/Chain/TransactionPipelineDemoTests.cs new file mode 100644 index 0000000..d516e40 --- /dev/null +++ b/test/PatternKit.Examples.Tests/Chain/TransactionPipelineDemoTests.cs @@ -0,0 +1,166 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.Chain; +using PatternKit.Examples.Chain.ConfigDriven; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; +using PaymentPipeline = PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineDemo.PaymentPipeline; + +namespace PatternKit.Examples.Tests.Chain; + + +[Collection("Culture")] +[Feature("Config-driven transaction pipeline (DI + fluent chains)")] +public sealed class TransactionPipelineDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + // --- helpers ------------------------------------------------------------- + + private static IConfiguration Config(string[] discounts, string[] rounding) + { + var dict = new Dictionary(); + for (var i = 0; i < discounts.Length; i++) + dict[$"Payment:Pipeline:DiscountRules:{i}"] = discounts[i]; + for (var i = 0; i < rounding.Length; i++) + dict[$"Payment:Pipeline:Rounding:{i}"] = rounding[i]; + + return new ConfigurationBuilder().AddInMemoryCollection(dict).Build(); + } + + private static PaymentPipeline BuildPipeline(string[] discounts, string[] rounding) + { + var services = new ServiceCollection(); + var cfg = Config(discounts, rounding); + services.AddPaymentPipeline(cfg); + return services.BuildServiceProvider().GetRequiredService(); + } + + private static TransactionContext Ctx( + IEnumerable items, + IEnumerable? tenders = null, + Customer? customer = null) + => new() + { + Customer = customer ?? new Customer(null, 35), + Items = items.ToList(), + Tenders = (tenders ?? []).ToList() + }; + + // item helper: choose price so total (with 8.75% tax) == 24.98 + private static readonly LineItem Item_22_97 = new("SKU-1", 22.97m); + + // --- scenarios ----------------------------------------------------------- + + [Scenario("Mixed tender: $20 cash then remainder on Visa — no nickel rounding (not cash-only)")] + [Fact] + public async Task MixedTender_NoNickelRounding() + { + PaymentPipeline Pipe() => BuildPipeline(discounts: [], + rounding: ["round:nickel-cash-only"]); + + var ctx = Ctx( + items: [Item_22_97], // Subtotal 22.97 -> tax 2.01 -> total 24.98 + tenders: + [ + new Tender(PaymentKind.Cash, CashGiven: 20m), + new Tender(PaymentKind.Card, CardAuthType.Contactless, CardVendor.Visa) + ]); + + await Given("a pipeline with nickel rounding only", Pipe) + .When("the pipeline runs", p => p.Run(ctx)) + .Then("preauth passes", r => r.Result.Ok) + .And("subtotal is 22.97", _ => ctx.Subtotal == 22.97m) + .And("tax is 2.01", _ => ctx.TaxTotal == 2.01m) + .And("rounding skipped", _ => ctx.RoundingDelta == 0m && ctx.Log.Any(x => x.Contains("skipped (not cash-only)"))) + .And("grand total remains 24.98", _ => ctx.GrandTotal == 24.98m) + .And("cash payment applied first ($20.00)", _ => ctx.Log.Any(x => x.Contains("paid: cash $20.00"))) + .And("card captures the remainder ($4.98)", _ => ctx.Log.Any(x => x.Contains("auth: captured via Visa $4.98"))) + .And("paid in full", _ => ctx is { RemainderDue: 0m, AmountPaid: 24.98m } && ctx.Result!.Value.Code == "paid") + .AssertPassed(); + } + + [Scenario("Cash-only: nickel rounding up to $25.00, single cash tender covers all")] + [Fact] + public async Task CashOnly_NickelRounding_Up() + { + PaymentPipeline Pipe() => BuildPipeline(discounts: [], + rounding: ["round:nickel-cash-only"]); + + var ctx = Ctx( + items: [Item_22_97], // pre-round total 24.98 + tenders: [new Tender(PaymentKind.Cash, CashGiven: 25m)]); + + await Given("a pipeline with nickel rounding only", Pipe) + .When("the pipeline runs", p => p.Run(ctx)) + .Then("preauth passes", r => r.Result.Ok) + .And("pre-round total is 24.98", _ => Math.Round(ctx.Subtotal - ctx.DiscountTotal + ctx.TaxTotal, 2) == 24.98m) + .And("nickel rounding adds $0.02", _ => ctx.RoundingDelta == 0.02m && ctx.Log.Any(x => x.Contains("nickel (cash-only) +$0.02"))) + .And("grand total becomes $25.00", _ => ctx.GrandTotal == 25.00m) + .And("cash pays $25.00", _ => ctx is { AmountPaid: 25.00m, CashChange: null }) + .And("result is paid", _ => ctx.RemainderDue == 0m && ctx.Result!.Value.Code == "paid") + .AssertPassed(); + } + + [Scenario("Charity round-up to the next dollar (independent of tender mix)")] + [Fact] + public async Task Charity_RoundUp_Works() + { + var ctx = Ctx( + items: + [ + Item_22_97, + new LineItem("CHARITY:RedCross", 0m) // signal charity + ], + tenders: [new Tender(PaymentKind.Card, CardAuthType.Chip, CardVendor.Visa)]); + + await Given("a pipeline with charity round-up only", Pipe) + .When("the pipeline runs", p => p.Run(ctx)) + .Then("preauth passes", r => r.Result.Ok) + .And("charity rounding adds $0.02", _ => ctx.RoundingDelta == 0.02m && ctx.Log.Any(x => x.Contains("charity RedCross"))) + .And("grand total = pre-round + $0.02", _ => + { + var pre = Math.Round(ctx.Subtotal - ctx.DiscountTotal + ctx.TaxTotal, 2); + return ctx.GrandTotal == pre + 0.02m; + }) + .And("paid by card", _ => ctx.Result!.Value.Code == "paid" && ctx.Log.Any(x => x.Contains("auth: captured"))) + .AssertPassed(); + return; + + PaymentPipeline Pipe() => BuildPipeline(discounts: [], + rounding: ["round:charity"]); + } + + [Scenario("Preauth: age-restricted item blocks underage customer")] + [Fact] + public async Task Preauth_AgeBlock() + { + var ctx = Ctx( + items: [new LineItem("CIGS", 9.99m, AgeRestricted: true)], + customer: new Customer(null, 19)); + + await Given("a default pipeline (no discounts/rounding)", Pipe) + .When("the pipeline runs", p => p.Run(ctx)) + .Then("fails preauth", r => r.Result is { Ok: false, Code: "age" }) + .And("log mentions age block", _ => ctx.Log.Any(x => x.Contains("age"))) + .AssertPassed(); + return; + + PaymentPipeline Pipe() => BuildPipeline(discounts: [], rounding: []); + } + + [Scenario("No tenders -> insufficient funds")] + [Fact] + public async Task NoTenders_Insufficient() + { + var ctx = Ctx(items: [new LineItem("SKU", 10m)]); // total > 0, but no payments + + await Given("a default pipeline", Pipe) + .When("the pipeline runs", p => p.Run(ctx)) + .Then("insufficient funds", r => r.Result is { Ok: false, Code: "insufficient" }) + .And("remainder due > 0", _ => ctx.RemainderDue > 0m) + .AssertPassed(); + return; + + PaymentPipeline Pipe() => BuildPipeline(discounts: [], rounding: []); + } +} \ No newline at end of file diff --git a/test/PatternKit.Examples.Tests/Fixtures/CultureFixture.cs b/test/PatternKit.Examples.Tests/Fixtures/CultureFixture.cs new file mode 100644 index 0000000..1496d0d --- /dev/null +++ b/test/PatternKit.Examples.Tests/Fixtures/CultureFixture.cs @@ -0,0 +1,29 @@ +using System.Globalization; + +namespace PatternKit.Examples.Tests.Fixtures; + +public sealed class CultureFixture : IDisposable +{ + private readonly CultureInfo _orig = CultureInfo.CurrentCulture; + private readonly CultureInfo _origUi = CultureInfo.CurrentUICulture; + + public CultureFixture() + { + var enUS = CultureInfo.GetCultureInfo("en-US"); + CultureInfo.CurrentCulture = enUS; + CultureInfo.CurrentUICulture = enUS; + CultureInfo.DefaultThreadCurrentCulture = enUS; + CultureInfo.DefaultThreadCurrentUICulture = enUS; + } + + public void Dispose() + { + CultureInfo.CurrentCulture = _orig; + CultureInfo.CurrentUICulture = _origUi; + } +} + +[CollectionDefinition("Culture")] +public class CultureCollection : ICollectionFixture +{ +} diff --git a/test/PatternKit.Examples.Tests/Generators/StrategySpecsTests.cs b/test/PatternKit.Examples.Tests/Generators/StrategySpecsTests.cs new file mode 100644 index 0000000..8c6cd1f --- /dev/null +++ b/test/PatternKit.Examples.Tests/Generators/StrategySpecsTests.cs @@ -0,0 +1,175 @@ +using PatternKit.Examples.Generators; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +// OrderRouter, ScoreLabeler, IntParser + +namespace PatternKit.Examples.Tests.Generators; + +[Feature("Generated strategies (Action / Result / Try)")] +public sealed class StrategySpecsTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + // -------- Helpers -------------------------------------------------------- + + private static (OrderRouter Router, List Log) BuildRouterWithLog() + { + var log = new List(); + var router = OrderRouter.Create() + .When((in c) => char.IsLetter(c)).Then((in c) => log.Add($"L:{c}")) + .When((in c) => char.IsDigit(c)).Then((in c) => log.Add($"D:{c}")) + .Default((in c) => log.Add($"O:{c}")) + .Build(); + return (router, log); + } + + private static (OrderRouter Router, List Log) Exec(in (OrderRouter Router, List Log) s, char c) + { + s.Router.Execute(c); + return s; + } + + private static bool LogIs(in (OrderRouter Router, List Log) s, params string[] expected) + => s.Log.Count == expected.Length && s.Log.Select((v, i) => v == expected[i]).All(v => v); + + private static ScoreLabeler BuildLabeler() => + ScoreLabeler.Create() + .When(static (in x) => x > 0).Then(static (in _) => "pos") + .When(static (in x) => x < 0).Then(static (in _) => "neg") + .Default(static (in _) => "zero") + .Build(); + + private static ScoreLabeler BuildLabeler_NoDefault() => + ScoreLabeler.Create() + .When(static (in x) => x > 0).Then(static (in _) => "pos") + .When(static (in x) => x < 0).Then(static (in _) => "neg") + .Build(); + + private static string Label(ScoreLabeler s, int x) => s.Execute(x); + + private static (bool Ok, int Value) ParseOnly(string input) + { + var p = IntParser.Create() + .Always(static (in s, out r) => + { + if (int.TryParse(s, out var tmp)) { r = tmp; return true; } + r = null; return false; + }) + .Build(); + + var ok = p.Execute(input, out var res); + return (ok, res ?? 0); + } + + private static (bool Ok, int Value) ParseWithFallback(string input) + { + var p = IntParser.Create() + .Always(static (in s, out r) => + { + if (int.TryParse(s, out var tmp)) { r = tmp; return true; } + r = null; return false; + }) + .Finally(static (in _, out r) => { r = 0; return true; }) + .Build(); + + var ok = p.Execute(input, out var res); + return (ok, res ?? 0); + } + + private static Exception? ExecuteAndCapture(OrderRouter r, char c) + { + try { r.Execute(c); return null; } + catch (Exception ex) { return ex; } + } + + // -------- Scenarios ------------------------------------------------------ + + [Scenario("OrderRouter routes letter, digit, then default")] + [Fact] + public async Task OrderRouter_Routes() + { + await Given("a router with log", BuildRouterWithLog) + .When("execute 'A'", s => Exec(s, 'A')) + .And("execute '7'", s => Exec(s, '7')) + .But("execute '@'", s => Exec(s, '@')) + .Then("log should be L:A, D:7, O:@", s => LogIs(s, "L:A", "D:7", "O:@")) + .AssertPassed(); + } + + [Scenario("OrderRouter TryExecute is false without default")] + [Fact] + public async Task OrderRouter_TryExecute_NoDefault() + { + await Given("router with only letter branch", + () => OrderRouter.Create() + .When(static (in c) => char.IsLetter(c)).Then(static (in _) => { }) + .Build()) + .When("TryExecute '!'", r => r.TryExecute('!')) + .Then("should be false", ok => ok == false) + .AssertPassed(); + } + + [Scenario("OrderRouter Execute throws without default")] + [Fact] + public async Task OrderRouter_Execute_NoDefault() + { + await Given("router with only letter branch", + () => OrderRouter.Create() + .When(static (in c) => char.IsLetter(c)).Then(static (in _) => { }) + .Build()) + .When("Execute '!'", r => ExecuteAndCapture(r, '!')) + .Then("is InvalidOperationException", ex => ex is InvalidOperationException) + .AssertPassed(); + } + + [Scenario("ScoreLabeler labels +, -, zero")] + [Fact] + public async Task ScoreLabeler_Labels() + { + await Given("a score labeler", BuildLabeler) + .When("label 3", s => Label(s, 3)) + .Then("pos", v => v == "pos") + .And("label -2", _ => Label(BuildLabeler(), -2) == "neg") + .But("label 0", _ => Label(BuildLabeler(), 0) == "zero") + .AssertPassed(); + } + + [Scenario("ScoreLabeler without default throws on zero")] + [Fact] + public async Task ScoreLabeler_NoDefault_Throws() + { + await Given("labeler without default", BuildLabeler_NoDefault) + .When("execute(0) capture exception", s => + { + try { _ = s.Execute(0); throw new Exception("Should not reach here"); } + catch (Exception ex) { return ex; } + }) + .Then("is InvalidOperationException", ex => ex is InvalidOperationException) + .AssertPassed(); + } + + [Scenario("IntParser success and failure (no fallback)")] + [Fact] + public async Task IntParser_NoFallback() + { + await Given("input '42'", () => "42") + .When("parse", ParseOnly) + .Then("ok && 42", r => r is { Ok: true, Value: 42 }) + .AssertPassed(); + + await Given("input 'x'", () => "x") + .When("parse", ParseOnly) + .Then("!ok && 0", r => r is { Ok: false, Value: 0 }) + .AssertPassed(); + } + + [Scenario("IntParser failure with fallback returns 0")] + [Fact] + public async Task IntParser_Fallback() + { + await Given("input 'x'", () => "x") + .When("parse with fallback", ParseWithFallback) + .Then("ok && 0", r => r is { Ok: true, Value: 0 }) + .AssertPassed(); + } +} diff --git a/test/PatternKit.Examples.Tests/PatternKit.Examples.Tests.csproj b/test/PatternKit.Examples.Tests/PatternKit.Examples.Tests.csproj new file mode 100644 index 0000000..e8ff087 --- /dev/null +++ b/test/PatternKit.Examples.Tests/PatternKit.Examples.Tests.csproj @@ -0,0 +1,37 @@ + + + + net8.0;net9.0 + enable + enable + false + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/test/PatternKit.Examples.Tests/Properties/AssemblyCoverage.cs b/test/PatternKit.Examples.Tests/Properties/AssemblyCoverage.cs new file mode 100644 index 0000000..598a1a7 --- /dev/null +++ b/test/PatternKit.Examples.Tests/Properties/AssemblyCoverage.cs @@ -0,0 +1,4 @@ +#if NETSTANDARD2_1 +// Exclude the entire assembly from coverage when built for netstandard2.1 +[assembly: System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +#endif \ No newline at end of file diff --git a/test/PatternKit.Tests/Examples/Coercion/CoercerTests.cs b/test/PatternKit.Examples.Tests/Strategies/Coercion/CoercerTests.cs similarity index 91% rename from test/PatternKit.Tests/Examples/Coercion/CoercerTests.cs rename to test/PatternKit.Examples.Tests/Strategies/Coercion/CoercerTests.cs index 78b2341..1ccf2bd 100644 --- a/test/PatternKit.Tests/Examples/Coercion/CoercerTests.cs +++ b/test/PatternKit.Examples.Tests/Strategies/Coercion/CoercerTests.cs @@ -4,7 +4,7 @@ using TinyBDD.Xunit; using Xunit.Abstractions; -namespace PatternKit.Tests.Examples.Coercion; +namespace PatternKit.Examples.Tests.Coercion; [Feature("Coercer (Strategy-based coercion)")] public class CoercerTests(ITestOutputHelper output) : TinyBddXunitBase(output) @@ -81,7 +81,7 @@ public async Task JsonBoolToBool() { await Given("json true", SourceJsonTrue) .When("coercing to bool", je => Coercer.From(je)) - .Then("should be true", v => v == true) + .Then("should be true", v => v) .AssertPassed(); } @@ -107,12 +107,12 @@ public async Task JsonArrayAndSingleStringToStringArray() { await Given("json [\"a\",\"b\",\"c\"]", SourceJsonStringArray) .When("coercing to string[]", je => Coercer.From(je)) - .Then("should be [a,b,c]", arr => arr is { Length: 3 } a && a[0] == "a" && a[1] == "b" && a[2] == "c") + .Then("should be [a,b,c]", arr => arr is ["a", "b", "c"]) .AssertPassed(); await Given("single string \"one\"", () => (object)"one") .When("coercing to string[]", CoerceStringArray) - .Then("should be [\"one\"]", arr => arr is { Length: 1 } a && a[0] == "one") + .Then("should be [\"one\"]", arr => arr is ["one"]) .AssertPassed(); } @@ -147,6 +147,17 @@ await Given("int 11 as object", () => (object)11) .Then("should be 11", v => v == 11) .AssertPassed(); } + + // ---------- Floating-point precision ---------- + [Scenario("Floating-point precision")] + [Fact] + public async Task FloatingPointPrecision() + { + await Given("float 1.23456789", () => (object)1.23456789) + .When("coercing to double", CoerceFloat) + .Then("should be around 1.2345678", v => v is > 1.234567f and < 1.234569f) + .AssertPassed(); + } // ---------- Extension method facade ---------- [Scenario("Extension method Coerce forwards to Coercer.From")] diff --git a/test/PatternKit.Examples.Tests/Strategies/Composed/ComposedStrategiesTests.Extended.cs b/test/PatternKit.Examples.Tests/Strategies/Composed/ComposedStrategiesTests.Extended.cs new file mode 100644 index 0000000..b33a113 --- /dev/null +++ b/test/PatternKit.Examples.Tests/Strategies/Composed/ComposedStrategiesTests.Extended.cs @@ -0,0 +1,251 @@ +using PatternKit.Behavioral.Strategy; +using PatternKit.Examples.Strategies.Composed; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.ComposedStrategiesTests +{ + // Service spies to verify short-circuit behavior in Push/IM gates. + sealed class SpyIdentity : IIdentityService + { + public int HasPushTokenCalls; + public int HasVerifiedEmailCalls; + public int HasSmsOptInCalls; + + public bool PushToken; + public bool VerifiedEmail; + public bool SmsOptIn; + + public ValueTask HasVerifiedEmailAsync(Guid userId, CancellationToken ct) + { + HasVerifiedEmailCalls++; + return new(VerifiedEmail); + } + + public ValueTask HasSmsOptInAsync(Guid userId, CancellationToken ct) + { + HasSmsOptInCalls++; + return new(SmsOptIn); + } + + public ValueTask HasPushTokenAsync(Guid userId, CancellationToken ct) + { + HasPushTokenCalls++; + return new(PushToken); + } + } + + sealed class SpyPresence : IPresenceService + { + public int OnlineCalls; + public int DndCalls; + public bool OnlineIm; + public bool DoNotDisturb; + + public ValueTask IsOnlineInImAsync(Guid userId, CancellationToken ct) + { + OnlineCalls++; + return new(OnlineIm); + } + + public ValueTask IsDoNotDisturbAsync(Guid userId, CancellationToken ct) + { + DndCalls++; + return new(DoNotDisturb); + } + } + + sealed class SpyRateLimiter : IRateLimiter + { + public int EmailCalls; + public int SmsCalls; + public int PushCalls; + public int ImCalls; + + public bool EmailAllowed = true; + public bool SmsAllowed = true; + public bool PushAllowed = true; + public bool ImAllowed = true; + + public ValueTask CanSendAsync(Channel channel, Guid userId, CancellationToken ct) + { + switch (channel) + { + case Channel.Email: + EmailCalls++; + return new(EmailAllowed); + case Channel.Sms: + SmsCalls++; + return new(SmsAllowed); + case Channel.Push: + PushCalls++; + return new(PushAllowed); + case Channel.Im: + ImCalls++; + return new(ImAllowed); + default: throw new ArgumentOutOfRangeException(nameof(channel), channel, null); + } + } + } + + sealed class ThrowsEmailSender : IEmailSender + { + public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => + ValueTask.FromCanceled(new CancellationToken(true)); // throws OCE on await + } + + sealed class FailingImSender : IImSender + { + public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => + new(new SendResult(Channel.Im, false, "simulated failure")); + } + + [Feature("Preference-aware composed strategies — extended behaviors (TinyBDD)")] + public sealed class ComposedStrategiesBddTests_Extended(ITestOutputHelper output) : TinyBddXunitBase(output) + { + private static SendContext Ctx() => new(Guid.NewGuid(), "hi", false); + + // ---------- 1) Push gate short-circuits when no token ---------- + [Scenario("Push gate short-circuits: no token -> never checks DND or rate; falls back to Email")] + [Fact] + public async Task PushGate_ShortCircuits_WhenNoToken() + { + await Given("push is first; no token; DND off; push rate would be allowed", () => + { + var id = new SpyIdentity { PushToken = false }; + var presence = new SpyPresence { DoNotDisturb = false }; + var rate = new SpyRateLimiter { PushAllowed = true }; + var prefs = new FakePrefs(); + prefs.Set([Channel.Push, Channel.Email]); + + var email = new FakeEmailSender(); + var sms = new FakeSmsSender(); + var push = new FakePushSender(); + var im = new FakeImSender(); + + var strategy = ComposedStrategies.BuildPreferenceAware(id, presence, rate, prefs, email, sms, push, im); + return (id, presence, rate, email, sms, push, im, strategy); + }) + .When("executing the strategy", + async Task<((SpyIdentity id, SpyPresence presence, SpyRateLimiter rate, FakeEmailSender email, FakeSmsSender sms, FakePushSender push + , FakeImSender im, AsyncStrategy strategy) t, SendResult r)> (t) => + { + var r = await t.strategy.ExecuteAsync(Ctx(), CancellationToken.None); + return (t, r); + }) + .Then("channel is Email", x => x.r.Channel == Channel.Email) + .And("push token checked once", x => x.t.id.HasPushTokenCalls == 1) + .And("DND not checked", x => x.t.presence.DndCalls == 0) + .And("push rate not checked", x => x.t.rate.PushCalls == 0) + .AssertPassed(); + } + + // ---------- 2) IM sender failure does not fall through ---------- + [Scenario("IM chosen but sender fails -> stays on IM (no fall-through)")] + [Fact] + public async Task ImSender_Failure_DoesNot_FallThrough() + { + await Given("IM first; IM gate passes; IM sender returns Success=false; Email viable as fallback", () => + { + var id = new FakeIdentity { VerifiedEmail = true }; + var presence = new FakePresence { OnlineIm = true }; + var rate = new FakeRateLimiter(); + rate.Set(Channel.Im, true); + var prefs = new FakePrefs(); + prefs.Set([Channel.Im, Channel.Email]); + + var email = new FakeEmailSender(); + var sms = new FakeSmsSender(); + var push = new FakePushSender(); + var im = new FailingImSender(); // returns Success=false + + var strategy = ComposedStrategies.BuildPreferenceAware(id, presence, rate, prefs, email, sms, push, im); + return (email, strategy); + }) + .When("executing the strategy", + async Task<((FakeEmailSender email, AsyncStrategy strategy) t, SendResult r)> (t) => + { + var r = await t.strategy.ExecuteAsync(Ctx(), CancellationToken.None); + return (t, r); + }) + .Then("IM remains the selected channel", x => x.r.Channel == Channel.Im) + .And("result is unsuccessful", x => !x.r.Success) + .And("no fall-through to Email", x => x.t.email.Calls == 0) + .AssertPassed(); + } + + // ---------- 3) Fallback Email throws -> cancellation propagates ---------- + [Scenario("All preferred channels blocked -> fallback Email throws -> propagates TaskCanceledException")] + [Fact] + public async Task Throwing_DefaultEmail_PropagatesCancellation() + { + await Given("Push/IM/SMS blocked so fallback to Email; Email sender throws OCE", () => + { + var id = new SpyIdentity(); + var presence = new SpyPresence(); + var rate = new SpyRateLimiter { PushAllowed = false, ImAllowed = false, SmsAllowed = false }; + var prefs = new FakePrefs(); + prefs.Set([Channel.Push, Channel.Im, Channel.Sms]); // forces fallback + + var email = new ThrowsEmailSender(); // throws on await + var sms = new FakeSmsSender(); + var push = new FakePushSender(); + var im = new FakeImSender(); + + var strategy = ComposedStrategies.BuildPreferenceAware(id, presence, rate, prefs, email, sms, push, im); + return strategy; + }) + .When("executing (expecting cancellation from fallback Email)", async Task<(bool threw, Exception? ex)> (strategy) => + { + try + { + await strategy.ExecuteAsync(Ctx(), CancellationToken.None); + return (threw: false, ex: null); + } + catch (Exception ex) + { + return (threw: true, ex); + } + }) + .Then("an exception was thrown", x => x.threw) + .And("it is TaskCanceledException", x => x.ex is TaskCanceledException) + .AssertPassed(); + } + + // ---------- 4) Preference order wins among ties ---------- + [Scenario("All channels viable: selection follows declared preference order (Sms first)")] + [Fact] + public async Task Preference_Order_Ties_BreakByOrder_NotCapability() + { + await Given("all gates/rates pass; preferences = [Sms, Push, Email, Im]", () => + { + var id = new FakeIdentity { SmsOptIn = true, PushToken = true, VerifiedEmail = true }; + var presence = new FakePresence { OnlineIm = true, DoNotDisturb = false }; + var rate = new FakeRateLimiter(); + + var prefs = new FakePrefs(); + prefs.Set([Channel.Sms, Channel.Push, Channel.Email, Channel.Im]); + + var email = new FakeEmailSender(); + var sms = new FakeSmsSender(); + var push = new FakePushSender(); + var im = new FakeImSender(); + + var strategy = ComposedStrategies.BuildPreferenceAware(id, presence, rate, prefs, email, sms, push, im); + return (email, sms, push, im, strategy); + }) + .When("executing the strategy", + async Task<((FakeEmailSender email, FakeSmsSender sms, FakePushSender push, FakeImSender im, AsyncStrategy + strategy) t, SendResult r)> (t) => + { + var r = await t.strategy.ExecuteAsync(Ctx(), CancellationToken.None); + return (t, r); + }) + .Then("Sms (first in order) is selected", x => x.r.Channel == Channel.Sms) + .And("Sms called once", x => x.t.sms.Calls == 1) + .And("others not called", x => x.t.push.Calls == 0 && x.t.email.Calls == 0 && x.t.im.Calls == 0) + .AssertPassed(); + } + } +} \ No newline at end of file diff --git a/test/PatternKit.Examples.Tests/Strategies/Composed/ComposedStrategiesTests.cs b/test/PatternKit.Examples.Tests/Strategies/Composed/ComposedStrategiesTests.cs new file mode 100644 index 0000000..2998a09 --- /dev/null +++ b/test/PatternKit.Examples.Tests/Strategies/Composed/ComposedStrategiesTests.cs @@ -0,0 +1,413 @@ +using PatternKit.Behavioral.Strategy; +using PatternKit.Examples.Strategies.Composed; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.ComposedStrategiesTests +{ + // ----------------- Fakes ----------------- + + sealed class FakeIdentity : IIdentityService + { + public bool VerifiedEmail; + public bool SmsOptIn; + public bool PushToken; + + public ValueTask HasVerifiedEmailAsync(Guid userId, CancellationToken ct) => new(VerifiedEmail); + public ValueTask HasSmsOptInAsync(Guid userId, CancellationToken ct) => new(SmsOptIn); + public ValueTask HasPushTokenAsync(Guid userId, CancellationToken ct) => new(PushToken); + } + + sealed class FakePresence : IPresenceService + { + public bool OnlineIm; + public bool DoNotDisturb; + + public int OnlineImCalls; + public int DoNotDisturbCalls; + + public ValueTask IsOnlineInImAsync(Guid userId, CancellationToken ct) + { + OnlineImCalls++; + return new(OnlineIm); + } + + public ValueTask IsDoNotDisturbAsync(Guid userId, CancellationToken ct) + { + DoNotDisturbCalls++; + return new(DoNotDisturb); + } + } + + sealed class FakeRateLimiter : IRateLimiter + { + private readonly Dictionary _allowed = new() + { + [Channel.Email] = true, + [Channel.Sms] = true, + [Channel.Push] = true, + [Channel.Im] = true, + }; + + public void Set(Channel ch, bool allowed) => _allowed[ch] = allowed; + + public ValueTask CanSendAsync(Channel channel, Guid userId, CancellationToken ct) + => new(_allowed.TryGetValue(channel, out var ok) && ok); + } + + sealed class FakePrefs : IPreferenceService + { + private Channel[] _order = []; + public void Set(Channel[] order) => _order = order; + public ValueTask GetPreferredOrderAsync(Guid userId, CancellationToken ct) => new(_order); + } + + abstract class CapturingSenderBase + { + public int Calls; + public SendContext? LastContext; + public bool ResultSuccess = true; + public string? Info; + + protected ValueTask CaptureAndReturn(SendContext ctx, CancellationToken ct, Channel ch) + { + Calls++; + LastContext = ctx; + return new(new SendResult(ch, ResultSuccess, Info)); + } + } + + sealed class FakeEmailSender : CapturingSenderBase, IEmailSender + { + public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => + CaptureAndReturn(ctx, ct, Channel.Email); + } + + sealed class FakeSmsSender : CapturingSenderBase, ISmsSender + { + public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => + CaptureAndReturn(ctx, ct, Channel.Sms); + } + + sealed class FakePushSender : CapturingSenderBase, IPushSender + { + public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => + CaptureAndReturn(ctx, ct, Channel.Push); + } + + sealed class FakeImSender : CapturingSenderBase, IImSender + { + public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => + CaptureAndReturn(ctx, ct, Channel.Im); + } + + // ----------------- BDD Tests ----------------- + + [Feature("Preference-aware composed strategies (TinyBDD)")] + public sealed class ComposedStrategiesBddTests(ITestOutputHelper output) : TinyBddXunitBase(output) + { + private sealed record Harness( + FakeIdentity Id, + FakePresence Presence, + FakeRateLimiter Rate, + FakePrefs Prefs, + FakeEmailSender Email, + FakeSmsSender Sms, + FakePushSender Push, + FakeImSender Im, + AsyncStrategy Strategy + ); + + private static Harness CreateHarness(Action? configure = null) + { + var id = new FakeIdentity(); + var presence = new FakePresence(); + var rate = new FakeRateLimiter(); + var prefs = new FakePrefs(); + var email = new FakeEmailSender(); + var sms = new FakeSmsSender(); + var push = new FakePushSender(); + var im = new FakeImSender(); + + var strategy = ComposedStrategies.BuildPreferenceAware( + id, presence, rate, prefs, email, sms, push, im); + + var h = new Harness(id, presence, rate, prefs, email, sms, push, im, strategy); + configure?.Invoke(h); + return h; + } + + private static SendContext Ctx(bool critical = false) => + new(Guid.NewGuid(), "Hello!", critical); + + // Utility: execute strategy and return (Harness, Result) for easy multi-Then checks + private static async Task<(Harness H, SendResult R)> Run(Harness h) + => (h, await h.Strategy.ExecuteAsync(Ctx(), CancellationToken.None)); + + private static async Task<(Harness H, SendResult R)> Run(Harness h, bool critical) + => (h, await h.Strategy.ExecuteAsync(Ctx(critical), CancellationToken.None)); + + // ---------- Scenarios ---------- + + [Scenario("Preference order: first viable -> Push")] + [Fact] + public async Task PrefOrder_FirstViable_Push() + { + await Given("a harness with Push first in prefs and all push guards passing", () => + CreateHarness(h => + { + h.Prefs.Set([Channel.Push, Channel.Im, Channel.Email]); + h.Id.PushToken = true; + h.Presence.DoNotDisturb = false; + h.Rate.Set(Channel.Push, true); + })) + .When("executing the strategy", Run) + .Then("result channel should be Push", x => x.R.Channel == Channel.Push) + .And("push called exactly once", x => x.H.Push.Calls == 1) + .And("no other senders called", x => x.H.Im.Calls == 0 && x.H.Email.Calls == 0 && x.H.Sms.Calls == 0) + .AssertPassed(); + } + + [Scenario("Preference order: skip non-viable Push and try next -> Im")] + [Fact] + public async Task PrefOrder_SkipNonViable_TryNext_Im() + { + await Given("push first but DND is on; IM is online and allowed", () => + CreateHarness(h => + { + h.Prefs.Set([Channel.Push, Channel.Im, Channel.Email]); + h.Id.PushToken = true; + h.Presence.DoNotDisturb = true; // NotDnd => false + h.Rate.Set(Channel.Push, true); + + h.Presence.OnlineIm = true; + h.Rate.Set(Channel.Im, true); + })) + .When("executing the strategy", Run) + .Then("result channel should be Im", x => x.R.Channel == Channel.Im) + .And("push not called", x => x.H.Push.Calls == 0) + .And("im called once", x => x.H.Im.Calls == 1) + .And("email not called", x => x.H.Email.Calls == 0) + .AssertPassed(); + } + + [Scenario("Critical: SMS is prepended regardless of prefs")] + [Fact] + public async Task Critical_Sms_First_RegardlessOfPrefs() + { + await Given("prefs omit Sms; Sms is viable", () => + CreateHarness(h => + { + h.Prefs.Set([Channel.Email, Channel.Push, Channel.Im]); + h.Id.SmsOptIn = true; + h.Rate.Set(Channel.Sms, true); + })) + .When("executing the strategy as critical", h => Run(h, critical: true)) + .Then("result channel should be Sms", x => x.R.Channel == Channel.Sms) + .And("only Sms called", x => x.H.Sms.Calls == 1 && x.H.Email.Calls == 0 && x.H.Push.Calls == 0 && x.H.Im.Calls == 0) + .AssertPassed(); + } + + [Scenario("Critical but SMS not viable -> falls back to default Email send")] + [Fact] + public async Task Critical_Sms_NotViable_FallsBack_To_DefaultEmailSend() + { + await Given("sms gate(s) fail entirely", () => + CreateHarness(h => + { + h.Id.SmsOptIn = false; // gate fails + h.Rate.Set(Channel.Sms, false); // rate fails too + })) + .When("executing the strategy as critical", h => Run(h, critical: true)) + .Then("result channel should be Email", x => x.R.Channel == Channel.Email) + .And("no Sms call, one Email call", x => x.H.Sms.Calls == 0 && x.H.Email.Calls == 1) + .AssertPassed(); + } + + [Scenario("Empty prefs -> defaults to Email")] + [Fact] + public async Task Prefs_Empty_Order_Defaults_To_Email() + { + await Given("no preferences set", () => CreateHarness(h => h.Prefs.Set([]))) + .When("executing the strategy", Run) + .Then("result channel should be Email", x => x.R.Channel == Channel.Email) + .And("email called once, others zero", x => x.H.Email.Calls == 1 && x.H.Push.Calls == 0 && x.H.Im.Calls == 0 && x.H.Sms.Calls == 0) + .AssertPassed(); + } + + [Scenario("Dedup: preserves first occurrence; attempts each gate once; sends next viable (Sms)")] + [Fact] + public async Task Dedup_Preserves_FirstOccurrence_AttemptsEachOnce() + { + await Given("prefs with duplicates; Im and Email gates fail; Sms viable", () => + CreateHarness(h => + { + h.Prefs.Set([Channel.Im, Channel.Email, Channel.Im, Channel.Sms, Channel.Email]); + h.Presence.OnlineIm = false; // IM gate fails + h.Id.VerifiedEmail = false; // Email gate fails + h.Id.SmsOptIn = true; + h.Rate.Set(Channel.Sms, true); + })) + .When("executing the strategy", Run) + .Then("result channel should be Sms", x => x.R.Channel == Channel.Sms) + .And("IM sender not called (gate failed)", x => x.H.Im.Calls == 0) + .And("IM gate evaluated once despite duplicates", x => x.H.Presence.OnlineImCalls == 1) + .And("Email not called (gate failed), Sms called once", x => x.H.Email.Calls == 0 && x.H.Sms.Calls == 1) + .AssertPassed(); + } + + [Scenario("Email gate respected when first in order -> skips to next (Sms)")] + [Fact] + public async Task EmailGate_Respected_WhenInOrder_SkipsToNext() + { + await Given("email first but gate fails; sms viable", () => + CreateHarness(h => + { + h.Prefs.Set([Channel.Email, Channel.Sms]); + h.Id.VerifiedEmail = false; // email gate fails + h.Id.SmsOptIn = true; + h.Rate.Set(Channel.Sms, true); + })) + .When("executing the strategy", Run) + .Then("result channel should be Sms", x => x.R.Channel == Channel.Sms) + .And("email not called; sms called once", x => x.H.Email.Calls == 0 && x.H.Sms.Calls == 1) + .AssertPassed(); + } + + [Scenario("Push gate requires: token, not DND, and rate (progressive checks)")] + [Fact] + public async Task PushGate_RequiresToken_NotDnd_Rate_ThenPasses() + { + // Start with Push preferred and Email verified for fallback + var baseHarness = CreateHarness(h => + { + h.Prefs.Set([Channel.Push, Channel.Email]); + h.Id.VerifiedEmail = true; + }); + + // 1) No token -> Email + await Given("no push token", () => baseHarness) + .When("executing", Run) + .Then("falls back to Email", x => x.R.Channel == Channel.Email) + .AssertPassed(); + + // 2) Token but DND on -> Email + await Given("push token present, DND on", () => + { + baseHarness.Id.PushToken = true; + baseHarness.Presence.DoNotDisturb = true; + return baseHarness; + }) + .When("executing", Run) + .Then("still Email due to DND", x => x.R.Channel == Channel.Email) + .AssertPassed(); + + // 3) Token, not DND, but rate limited -> Email + await Given("token, not DND, push rate limited", () => + { + baseHarness.Presence.DoNotDisturb = false; + baseHarness.Rate.Set(Channel.Push, false); + return baseHarness; + }) + .When("executing", Run) + .Then("still Email due to rate limit", x => x.R.Channel == Channel.Email) + .AssertPassed(); + + // 4) All good -> Push (and push called once overall) + await Given("all push guards pass", () => + { + baseHarness.Rate.Set(Channel.Push, true); + return baseHarness; + }) + .When("executing", Run) + .Then("now Push is selected", x => x.R.Channel == Channel.Push) + .And("push called exactly once", x => x.H.Push.Calls == 1) + .AssertPassed(); + } + + [Scenario("Im gate requires: online and rate")] + [Fact] + public async Task ImGate_RequiresOnline_AndRate() + { + var baseHarness = CreateHarness(h => + { + h.Prefs.Set([Channel.Im, Channel.Email]); + h.Id.VerifiedEmail = true; // email fallback + }); + + // 1) Offline IM -> Email + await Given("IM offline", () => baseHarness) + .When("executing", Run) + .Then("fallback Email", x => x.R.Channel == Channel.Email) + .AssertPassed(); + + // 2) Online but rate limited -> Email + await Given("IM online but rate limited", () => + { + baseHarness.Presence.OnlineIm = true; + baseHarness.Rate.Set(Channel.Im, false); + return baseHarness; + }) + .When("executing", Run) + .Then("fallback Email", x => x.R.Channel == Channel.Email) + .AssertPassed(); + + // 3) Online and allowed -> Im (once) + await Given("IM online and rate allowed", () => + { + baseHarness.Rate.Set(Channel.Im, true); + return baseHarness; + }) + .When("executing", Run) + .Then("select Im", x => x.R.Channel == Channel.Im) + .And("im called once", x => x.H.Im.Calls == 1) + .AssertPassed(); + } + + [Scenario("Rate limiter applies per channel; picks first allowed (Sms)")] + [Fact] + public async Task RateLimiter_Applies_PerChannel() + { + await Given("email disabled; sms allowed; im/push disabled", () => + CreateHarness(h => + { + h.Prefs.Set([Channel.Email, Channel.Sms, Channel.Im, Channel.Push]); + + h.Id.VerifiedEmail = true; + h.Id.SmsOptIn = true; + h.Presence.OnlineIm = true; + h.Id.PushToken = true; + h.Presence.DoNotDisturb = false; + + h.Rate.Set(Channel.Email, false); + h.Rate.Set(Channel.Sms, true); + h.Rate.Set(Channel.Im, false); + h.Rate.Set(Channel.Push, false); + })) + .When("executing", Run) + .Then("Sms chosen", x => x.R.Channel == Channel.Sms) + .And("email not called", x => x.H.Email.Calls == 0) + .And("sms called once", x => x.H.Sms.Calls == 1) + .And("im/push not called", x => x.H.Im.Calls == 0 && x.H.Push.Calls == 0) + .AssertPassed(); + } + + [Scenario("Default Email fallback ignores Email gate by design")] + [Fact] + public async Task DefaultEmailFallback_IgnoresEmailGate_ByDesign() + { + await Given("push is preferred but fails; email gate would fail but fallback still sends", () => + CreateHarness(h => + { + h.Prefs.Set([Channel.Push]); // Push will fail gate + h.Id.PushToken = false; + h.Id.VerifiedEmail = false; // email gate would fail, but fallback still sends + })) + .When("executing", Run) + .Then("email selected via fallback", x => x.R.Channel == Channel.Email) + .And("email called once", x => x.H.Email.Calls == 1) + .AssertPassed(); + } + } +} \ No newline at end of file diff --git a/test/PatternKit.Examples.Tests/packages.lock.json b/test/PatternKit.Examples.Tests/packages.lock.json new file mode 100644 index 0000000..9469ade --- /dev/null +++ b/test/PatternKit.Examples.Tests/packages.lock.json @@ -0,0 +1,538 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.4, )", + "resolved": "6.0.4", + "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" + }, + "Microsoft.Extensions.Configuration": { + "type": "Direct", + "requested": "[9.0.9, )", + "resolved": "9.0.9", + "contentHash": "w87wF/90/VI0ZQBhf4rbMEeyEy0vi2WKjFmACsNAKNaorY+ZlVz7ddyXkbADvaWouMKffNmR0yQOGcrvSSvKGg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.9", + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Direct", + "requested": "[9.0.9, )", + "resolved": "9.0.9", + "contentHash": "zQV2WOSP+3z1EuK91ULxfGgo2Y75bTRnmJHp08+w/YXAyekZutX/qCd88/HOMNh35MDW9mJJJxPpMPS+1Rww8A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Direct", + "requested": "[9.0.9, )", + "resolved": "9.0.9", + "contentHash": "ORA4dICNz7cuwupPkjXpSuoiK6GMg0aygInBIQCCFEimwoHntRKdJqB59faxq2HHJuTPW3NsZm5EjN5P5Zh6nQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.9", + "Microsoft.Extensions.Logging.Abstractions": "9.0.9" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.14.1, )", + "resolved": "17.14.1", + "contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==", + "dependencies": { + "Microsoft.CodeCoverage": "17.14.1", + "Microsoft.TestPlatform.TestHost": "17.14.1" + } + }, + "TinyBDD.Xunit": { + "type": "Direct", + "requested": "[0.8.3, )", + "resolved": "0.8.3", + "contentHash": "BuDWkiCdzmgQt9feQNoz81YpWzrQp6YsMvVsTlvVfK9pAS+e7X8MKn+JGAaR+qwiBv5OCRqsTprUoKuNSo48jQ==", + "dependencies": { + "TinyBDD": "0.8.3", + "xunit.abstractions": "2.0.3", + "xunit.extensibility.core": "2.9.3" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.4, )", + "resolved": "3.1.4", + "contentHash": "5mj99LvCqrq3CNi06xYdyIAXOEh+5b33F2nErCzI5zWiDdLHXiPXEWFSUAF8zlIv0ZWqjZNCwHTQeAPYbF3pCg==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "p5RKAY9POvs3axwA/AQRuJeM8AHuE8h4qbP1NxQeGm0ep46aXz1oCLAp/oOYxX1GsjStgdhHrN3XXLLXr0+b3w==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "6SIp/6Bngk4jm2W36JekZbiIbFPdE/eMUtrJEqIqHGpd1zar3jvgnwxnpWQfzUiGrkyY8q8s6V82zkkEZozghA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.9" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "/hymojfWbE9AlDOa0mczR44m00Jj+T3+HZO0ZnVTI032fVycI0ZbNOVFP6kqZMcXiLSYXzR2ilcwaRi6dzeGyA==" + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "YHGmxccrVZ2Ar3eI+/NdbOHkd1/HzrHvmQ5yBsp0Gl7jTyBe6qcXNYjUt9v9JIO+Z14la44+YYEe63JSqs1fYg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Options": "9.0.9", + "System.Diagnostics.DiagnosticSource": "9.0.9" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "M1ZhL9QkBQ/k6l/Wjgcli5zrV86HzytQ+gQiNtk9vs9Ge1fb17KKZil9T6jd15p2x/BGfXpup7Hg55CC0kkfig==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "FEgpSF+Z9StMvrsSViaybOBwR0f0ZZxDm8xV5cSOFiXN/t+ys+rwAlTd/6yG7Ld1gfppgvLcMasZry3GsI9lGA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "System.Diagnostics.DiagnosticSource": "9.0.9" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "loxGGHE1FC2AefwPHzrjPq7X92LQm64qnU/whKfo6oWaceewPUVYQJBJs3S3E2qlWwnCpeZ+dGCPTX+5dgVAuQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "n4DCdnn2qs6V5U06Sx62FySEAZsJiJJgOzrPHDh9hPK7c2W8hEabC76F3Re3tGPjpiKa02RvB6FxZyxo8iICzg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.9", + "Microsoft.Extensions.Configuration.Binder": "9.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Options": "9.0.9", + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Options.DataAnnotations": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "Al+1FXnKKFygTXz0Zsa1+jYEPvsx5dKavlJxMXjRbrL6lmBhQZsVMhjuNB5lWvdRhdoxt5y/Q3v5kbLZLsXWdA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Options": "9.0.9" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "z4pyMePOrl733ltTowbN565PxBw1oAr8IHmIXNDiDqd22nFpYltX9KhrNC/qBWAG1/Zx5MHX+cOYhWJQYCO/iw==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ==", + "dependencies": { + "System.Reflection.Metadata": "8.0.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.14.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "8hy61dsFYYSDjT9iTAfygGMU3A0EAnG69x5FUXeKsCjMhBmtTBt4UMUEW3ipprFoorOW6Jw/7hDMjXtlrsOvVQ==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0" + } + }, + "TinyBDD": { + "type": "Transitive", + "resolved": "0.8.3", + "contentHash": "cUd2UGU5WoBmy/s4N5hI3lw4AVfuZXeuXrFJlP4RBdcF2YB5Tc3yL9jKsMfcLalXC2Tb0M+uublpyqE/cGfs8Q==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "patternkit.core": { + "type": "Project" + }, + "patternkit.examples": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "[9.0.9, )", + "Microsoft.Extensions.Configuration.Binder": "[9.0.9, )", + "Microsoft.Extensions.Options": "[9.0.9, )", + "Microsoft.Extensions.Options.ConfigurationExtensions": "[9.0.9, )", + "Microsoft.Extensions.Options.DataAnnotations": "[9.0.9, )", + "PatternKit.Core": "[1.0.0, )", + "PatternKit.Generators.Abstractions": "[1.0.0, )" + } + }, + "patternkit.generators.abstractions": { + "type": "Project" + } + }, + "net9.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.4, )", + "resolved": "6.0.4", + "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" + }, + "Microsoft.Extensions.Configuration": { + "type": "Direct", + "requested": "[9.0.9, )", + "resolved": "9.0.9", + "contentHash": "w87wF/90/VI0ZQBhf4rbMEeyEy0vi2WKjFmACsNAKNaorY+ZlVz7ddyXkbADvaWouMKffNmR0yQOGcrvSSvKGg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.9", + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Direct", + "requested": "[9.0.9, )", + "resolved": "9.0.9", + "contentHash": "zQV2WOSP+3z1EuK91ULxfGgo2Y75bTRnmJHp08+w/YXAyekZutX/qCd88/HOMNh35MDW9mJJJxPpMPS+1Rww8A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Direct", + "requested": "[9.0.9, )", + "resolved": "9.0.9", + "contentHash": "ORA4dICNz7cuwupPkjXpSuoiK6GMg0aygInBIQCCFEimwoHntRKdJqB59faxq2HHJuTPW3NsZm5EjN5P5Zh6nQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.9", + "Microsoft.Extensions.Logging.Abstractions": "9.0.9" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.14.1, )", + "resolved": "17.14.1", + "contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==", + "dependencies": { + "Microsoft.CodeCoverage": "17.14.1", + "Microsoft.TestPlatform.TestHost": "17.14.1" + } + }, + "TinyBDD.Xunit": { + "type": "Direct", + "requested": "[0.8.3, )", + "resolved": "0.8.3", + "contentHash": "BuDWkiCdzmgQt9feQNoz81YpWzrQp6YsMvVsTlvVfK9pAS+e7X8MKn+JGAaR+qwiBv5OCRqsTprUoKuNSo48jQ==", + "dependencies": { + "TinyBDD": "0.8.3", + "xunit.abstractions": "2.0.3", + "xunit.extensibility.core": "2.9.3" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.4, )", + "resolved": "3.1.4", + "contentHash": "5mj99LvCqrq3CNi06xYdyIAXOEh+5b33F2nErCzI5zWiDdLHXiPXEWFSUAF8zlIv0ZWqjZNCwHTQeAPYbF3pCg==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "p5RKAY9POvs3axwA/AQRuJeM8AHuE8h4qbP1NxQeGm0ep46aXz1oCLAp/oOYxX1GsjStgdhHrN3XXLLXr0+b3w==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "6SIp/6Bngk4jm2W36JekZbiIbFPdE/eMUtrJEqIqHGpd1zar3jvgnwxnpWQfzUiGrkyY8q8s6V82zkkEZozghA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.9" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "/hymojfWbE9AlDOa0mczR44m00Jj+T3+HZO0ZnVTI032fVycI0ZbNOVFP6kqZMcXiLSYXzR2ilcwaRi6dzeGyA==" + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "YHGmxccrVZ2Ar3eI+/NdbOHkd1/HzrHvmQ5yBsp0Gl7jTyBe6qcXNYjUt9v9JIO+Z14la44+YYEe63JSqs1fYg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Options": "9.0.9" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "M1ZhL9QkBQ/k6l/Wjgcli5zrV86HzytQ+gQiNtk9vs9Ge1fb17KKZil9T6jd15p2x/BGfXpup7Hg55CC0kkfig==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "FEgpSF+Z9StMvrsSViaybOBwR0f0ZZxDm8xV5cSOFiXN/t+ys+rwAlTd/6yG7Ld1gfppgvLcMasZry3GsI9lGA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "loxGGHE1FC2AefwPHzrjPq7X92LQm64qnU/whKfo6oWaceewPUVYQJBJs3S3E2qlWwnCpeZ+dGCPTX+5dgVAuQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "n4DCdnn2qs6V5U06Sx62FySEAZsJiJJgOzrPHDh9hPK7c2W8hEabC76F3Re3tGPjpiKa02RvB6FxZyxo8iICzg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.9", + "Microsoft.Extensions.Configuration.Binder": "9.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Options": "9.0.9", + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Options.DataAnnotations": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "Al+1FXnKKFygTXz0Zsa1+jYEPvsx5dKavlJxMXjRbrL6lmBhQZsVMhjuNB5lWvdRhdoxt5y/Q3v5kbLZLsXWdA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Options": "9.0.9" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "z4pyMePOrl733ltTowbN565PxBw1oAr8IHmIXNDiDqd22nFpYltX9KhrNC/qBWAG1/Zx5MHX+cOYhWJQYCO/iw==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ==", + "dependencies": { + "System.Reflection.Metadata": "8.0.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.14.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0" + } + }, + "TinyBDD": { + "type": "Transitive", + "resolved": "0.8.3", + "contentHash": "cUd2UGU5WoBmy/s4N5hI3lw4AVfuZXeuXrFJlP4RBdcF2YB5Tc3yL9jKsMfcLalXC2Tb0M+uublpyqE/cGfs8Q==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "patternkit.core": { + "type": "Project" + }, + "patternkit.examples": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "[9.0.9, )", + "Microsoft.Extensions.Configuration.Binder": "[9.0.9, )", + "Microsoft.Extensions.Options": "[9.0.9, )", + "Microsoft.Extensions.Options.ConfigurationExtensions": "[9.0.9, )", + "Microsoft.Extensions.Options.DataAnnotations": "[9.0.9, )", + "PatternKit.Core": "[1.0.0, )", + "PatternKit.Generators.Abstractions": "[1.0.0, )" + } + }, + "patternkit.generators.abstractions": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/test/PatternKit.Generators.Tests/PatternKit.Generators.Tests.csproj b/test/PatternKit.Generators.Tests/PatternKit.Generators.Tests.csproj new file mode 100644 index 0000000..79168a2 --- /dev/null +++ b/test/PatternKit.Generators.Tests/PatternKit.Generators.Tests.csproj @@ -0,0 +1,32 @@ + + + + net8.0;net9.0 + enable + enable + false + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/test/PatternKit.Generators.Tests/Properties/AssemblyCoverage.cs b/test/PatternKit.Generators.Tests/Properties/AssemblyCoverage.cs new file mode 100644 index 0000000..598a1a7 --- /dev/null +++ b/test/PatternKit.Generators.Tests/Properties/AssemblyCoverage.cs @@ -0,0 +1,4 @@ +#if NETSTANDARD2_1 +// Exclude the entire assembly from coverage when built for netstandard2.1 +[assembly: System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +#endif \ No newline at end of file diff --git a/test/PatternKit.Generators.Tests/RoslynTestHelpers.cs b/test/PatternKit.Generators.Tests/RoslynTestHelpers.cs new file mode 100644 index 0000000..21e557c --- /dev/null +++ b/test/PatternKit.Generators.Tests/RoslynTestHelpers.cs @@ -0,0 +1,85 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace PatternKit.Generators.Tests; + +public static class RoslynTestHelpers +{ + private static readonly StringComparison OrdIgnoreCase = StringComparison.InvariantCultureIgnoreCase; + + public static CSharpCompilation CreateCompilation( + string source, + string assemblyName, + LanguageVersion lang = LanguageVersion.Preview, + params MetadataReference[]? extra) + { + var parse = new CSharpParseOptions(lang); + var tree = CSharpSyntaxTree.ParseText(source, parse); + + var refs = new List + { + RefFromTPA("System.Private.CoreLib.dll"), + RefFromTPA("System.Runtime.dll"), + RefFromTPA("System.Console.dll"), + RefFromTPA("System.Collections.dll"), + RefFromTPA("System.Linq.dll"), + RefFromTPA("System.Memory.dll"), + RefFromTPA("System.Runtime.Extensions.dll"), + RefFromTPA("PatternKit.Generators.Abstractions.dll"), + RefFromTPA("netstandard.dll"), + }; + if (extra is not null) refs.AddRange(extra); + + return CSharpCompilation.Create( + assemblyName, + [tree], + refs, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } + + + private static MetadataReference RefFromTPA(string simpleName) + { + var tpa = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator); + var path = tpa.First(p => string.Equals(Path.GetFileName(p), simpleName, OrdIgnoreCase)); + return MetadataReference.CreateFromFile(path); + } + + public static GeneratorDriver Run( + Compilation compilation, + IIncrementalGenerator gen, + out GeneratorDriverRunResult result, + out Compilation updated) + { + var parseOptions = (CSharpParseOptions)compilation.SyntaxTrees.First().Options; + + // Convert incremental -> classic source generator + var sg = gen.AsSourceGenerator(); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + generators: [sg], + parseOptions: parseOptions); + + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out updated, out _); + result = driver.GetRunResult(); + return driver; + } + + public static GeneratorDriver Run( + Compilation compilation, + ISourceGenerator gen, + out GeneratorDriverRunResult result, + out Compilation updated) + { + var parseOptions = (CSharpParseOptions)compilation.SyntaxTrees.First().Options; + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + generators: [gen], + parseOptions: parseOptions); + + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out updated, out _); + result = driver.GetRunResult(); + return driver; + } +} \ No newline at end of file diff --git a/test/PatternKit.Generators.Tests/StrategyGeneratorTests.cs b/test/PatternKit.Generators.Tests/StrategyGeneratorTests.cs new file mode 100644 index 0000000..a0acf04 --- /dev/null +++ b/test/PatternKit.Generators.Tests/StrategyGeneratorTests.cs @@ -0,0 +1,153 @@ +using System.Runtime.Loader; +using Microsoft.CodeAnalysis; +using PatternKit.Common; +using PatternKit.Creational.Builder; + +namespace PatternKit.Generators.Tests; + +public class StrategyGeneratorTests +{ + // A small user file that triggers all 3 strategies + private const string Specs =""" + using PatternKit.Generators; + + namespace PatternKit.Examples.Generators; + + [GenerateStrategy(nameof(OrderRouter), typeof(char), StrategyKind.Action)] + public partial class OrderRouter + { + } + + [GenerateStrategy(nameof(ScoreLabeler), typeof(int), typeof(string), StrategyKind.Result)] + public partial class ScoreLabeler + { + } + + [GenerateStrategy(nameof(IntParser), typeof(string), typeof(int), StrategyKind.Try)] + public partial class IntParser + { + } + """; + + [Fact] + public void Generates_All_Strategies_Without_Diagnostics() + { + // The generated code references PatternKit.Core (BranchBuilder/ChainBuilder/Throw), + // so add that assembly as a metadata reference to make compilation succeed. + var coreRef = MetadataReference.CreateFromFile(typeof(BranchBuilder<,>).Assembly.Location); + var commonRef = MetadataReference.CreateFromFile(typeof(Throw).Assembly.Location); + + var comp = RoslynTestHelpers.CreateCompilation( + Specs, + assemblyName: nameof(Generates_All_Strategies_Without_Diagnostics), + extra: [coreRef, commonRef]); + + var gen = new StrategyGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + // No generator diagnostics + Assert.All(run.Results, r => Assert.True(r.Diagnostics.Length == 0)); + + // Confirm we generated expected files + var names = run.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); + Assert.Contains("OrderRouter.g.cs", names); + Assert.Contains("ScoreLabeler.g.cs", names); + Assert.Contains("IntParser.g.cs", names); + + // And the updated compilation actually compiles + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void OrderRouter_Wires_Predicate_And_Action() + { + var coreRef = MetadataReference.CreateFromFile(typeof(BranchBuilder<,>).Assembly.Location); + var commonRef = MetadataReference.CreateFromFile(typeof(Throw).Assembly.Location); + + var user = Specs + """ + public static class Demo + { + public static string Run() + { + var log = new System.Collections.Generic.List(); + var r = new OrderRouter.Builder() + .When((in char c) => char.IsLetter(c)).Then((in char c) => log.Add($"L:{c}")) + .When((in char c) => char.IsDigit(c)).Then((in char c) => log.Add($"D:{c}")) + .Default((in char c) => log.Add($"O:{c}")) + .Build(); + + r.Execute('A'); + r.Execute('7'); + r.Execute('@'); + + return string.Join("|", log); + } + } + """; + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(OrderRouter_Wires_Predicate_And_Action), + extra: [coreRef, commonRef]); + var gen = new StrategyGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // (Optional) load & invoke Demo.Run via reflection to assert behavior + using var pe = new MemoryStream(); + using var pdb = new MemoryStream(); + var res = updated.Emit(pe, pdb); + Assert.True(res.Success); + pe.Position = 0; + + var asm = AssemblyLoadContext.Default.LoadFromStream(pe, pdb); + var run = asm.GetType("PatternKit.Examples.Generators.Demo")! + .GetMethod("Run")! + .Invoke(null, null) as string; + + Assert.Equal("L:A|D:7|O:@", run); + } + + [Fact] + public void IntParser_Try_Handler_Signature_Works() + { + var coreRef = MetadataReference.CreateFromFile(typeof(ChainBuilder<>).Assembly.Location); + var commonRef = MetadataReference.CreateFromFile(typeof(Throw).Assembly.Location); + + var user = Specs + """ + public static class ParseDemo + { + public static (bool ok, int value) Parse(string s) + { + var p = IntParser.Create() + .Always(static (in string x, out int? r) => { if (int.TryParse(x, out var t)) { r = t; return true; } r = null; return false; }) + .Finally(static (in string _, out int? r) => { r = 0; return true; }) + .Build(); + var ok = p.Execute(s, out var v); + return (ok, v ?? 0); + } + } + """; + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(IntParser_Try_Handler_Signature_Works), + extra: [coreRef, commonRef]); + var gen = new StrategyGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + using var pe = new MemoryStream(); + using var pdb = new MemoryStream(); + var res = updated.Emit(pe, pdb); + Assert.True(res.Success); + pe.Position = 0; + + var asm = AssemblyLoadContext.Default.LoadFromStream(pe, pdb); + var parse = asm.GetType("PatternKit.Examples.Generators.ParseDemo")! + .GetMethod("Parse")!; + Assert.Equal((true, 42), (ValueTuple)parse.Invoke(null, ["42"])!); + Assert.Equal((true, 0), (ValueTuple)parse.Invoke(null, ["x"])!); + } +} \ No newline at end of file diff --git a/test/PatternKit.Generators.Tests/packages.lock.json b/test/PatternKit.Generators.Tests/packages.lock.json new file mode 100644 index 0000000..e2cbe99 --- /dev/null +++ b/test/PatternKit.Generators.Tests/packages.lock.json @@ -0,0 +1,452 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.4, )", + "resolved": "6.0.4", + "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" + }, + "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" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.14.1, )", + "resolved": "17.14.1", + "contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==", + "dependencies": { + "Microsoft.CodeCoverage": "17.14.1", + "Microsoft.TestPlatform.TestHost": "17.14.1" + } + }, + "TinyBDD": { + "type": "Direct", + "requested": "[0.8.3, )", + "resolved": "0.8.3", + "contentHash": "cUd2UGU5WoBmy/s4N5hI3lw4AVfuZXeuXrFJlP4RBdcF2YB5Tc3yL9jKsMfcLalXC2Tb0M+uublpyqE/cGfs8Q==" + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.4, )", + "resolved": "3.1.4", + "contentHash": "5mj99LvCqrq3CNi06xYdyIAXOEh+5b33F2nErCzI5zWiDdLHXiPXEWFSUAF8zlIv0ZWqjZNCwHTQeAPYbF3pCg==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "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" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "p5RKAY9POvs3axwA/AQRuJeM8AHuE8h4qbP1NxQeGm0ep46aXz1oCLAp/oOYxX1GsjStgdhHrN3XXLLXr0+b3w==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "6SIp/6Bngk4jm2W36JekZbiIbFPdE/eMUtrJEqIqHGpd1zar3jvgnwxnpWQfzUiGrkyY8q8s6V82zkkEZozghA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.9" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "/hymojfWbE9AlDOa0mczR44m00Jj+T3+HZO0ZnVTI032fVycI0ZbNOVFP6kqZMcXiLSYXzR2ilcwaRi6dzeGyA==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "loxGGHE1FC2AefwPHzrjPq7X92LQm64qnU/whKfo6oWaceewPUVYQJBJs3S3E2qlWwnCpeZ+dGCPTX+5dgVAuQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "n4DCdnn2qs6V5U06Sx62FySEAZsJiJJgOzrPHDh9hPK7c2W8hEabC76F3Re3tGPjpiKa02RvB6FxZyxo8iICzg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.9", + "Microsoft.Extensions.Configuration.Binder": "9.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Options": "9.0.9", + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Options.DataAnnotations": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "Al+1FXnKKFygTXz0Zsa1+jYEPvsx5dKavlJxMXjRbrL6lmBhQZsVMhjuNB5lWvdRhdoxt5y/Q3v5kbLZLsXWdA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Options": "9.0.9" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "z4pyMePOrl733ltTowbN565PxBw1oAr8IHmIXNDiDqd22nFpYltX9KhrNC/qBWAG1/Zx5MHX+cOYhWJQYCO/iw==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ==", + "dependencies": { + "System.Reflection.Metadata": "8.0.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.14.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "QhkXUl2gNrQtvPmtBTQHb0YsUrDiDQ2QS09YbtTTiSjGcf7NBqtYbrG/BE06zcBPCKEwQGzIv13IVdXNOSub2w==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "ANiqLu3DxW9kol/hMmTWbt3414t9ftdIuiIU7j80okq2YzAueo120M442xk1kDJWtmZTqWQn7wHDvMRipVOEOQ==", + "dependencies": { + "System.Collections.Immutable": "9.0.0" + } + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "patternkit.core": { + "type": "Project" + }, + "patternkit.examples": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "[9.0.9, )", + "Microsoft.Extensions.Configuration.Binder": "[9.0.9, )", + "Microsoft.Extensions.Options": "[9.0.9, )", + "Microsoft.Extensions.Options.ConfigurationExtensions": "[9.0.9, )", + "Microsoft.Extensions.Options.DataAnnotations": "[9.0.9, )", + "PatternKit.Core": "[1.0.0, )", + "PatternKit.Generators.Abstractions": "[1.0.0, )" + } + }, + "patternkit.generators": { + "type": "Project" + }, + "patternkit.generators.abstractions": { + "type": "Project" + } + }, + "net9.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.4, )", + "resolved": "6.0.4", + "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" + }, + "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" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.14.1, )", + "resolved": "17.14.1", + "contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==", + "dependencies": { + "Microsoft.CodeCoverage": "17.14.1", + "Microsoft.TestPlatform.TestHost": "17.14.1" + } + }, + "TinyBDD": { + "type": "Direct", + "requested": "[0.8.3, )", + "resolved": "0.8.3", + "contentHash": "cUd2UGU5WoBmy/s4N5hI3lw4AVfuZXeuXrFJlP4RBdcF2YB5Tc3yL9jKsMfcLalXC2Tb0M+uublpyqE/cGfs8Q==" + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.4, )", + "resolved": "3.1.4", + "contentHash": "5mj99LvCqrq3CNi06xYdyIAXOEh+5b33F2nErCzI5zWiDdLHXiPXEWFSUAF8zlIv0ZWqjZNCwHTQeAPYbF3pCg==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "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" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "p5RKAY9POvs3axwA/AQRuJeM8AHuE8h4qbP1NxQeGm0ep46aXz1oCLAp/oOYxX1GsjStgdhHrN3XXLLXr0+b3w==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "6SIp/6Bngk4jm2W36JekZbiIbFPdE/eMUtrJEqIqHGpd1zar3jvgnwxnpWQfzUiGrkyY8q8s6V82zkkEZozghA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.9" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "/hymojfWbE9AlDOa0mczR44m00Jj+T3+HZO0ZnVTI032fVycI0ZbNOVFP6kqZMcXiLSYXzR2ilcwaRi6dzeGyA==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "loxGGHE1FC2AefwPHzrjPq7X92LQm64qnU/whKfo6oWaceewPUVYQJBJs3S3E2qlWwnCpeZ+dGCPTX+5dgVAuQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "n4DCdnn2qs6V5U06Sx62FySEAZsJiJJgOzrPHDh9hPK7c2W8hEabC76F3Re3tGPjpiKa02RvB6FxZyxo8iICzg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.9", + "Microsoft.Extensions.Configuration.Binder": "9.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Options": "9.0.9", + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Options.DataAnnotations": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "Al+1FXnKKFygTXz0Zsa1+jYEPvsx5dKavlJxMXjRbrL6lmBhQZsVMhjuNB5lWvdRhdoxt5y/Q3v5kbLZLsXWdA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Options": "9.0.9" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "z4pyMePOrl733ltTowbN565PxBw1oAr8IHmIXNDiDqd22nFpYltX9KhrNC/qBWAG1/Zx5MHX+cOYhWJQYCO/iw==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ==", + "dependencies": { + "System.Reflection.Metadata": "8.0.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.14.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "QhkXUl2gNrQtvPmtBTQHb0YsUrDiDQ2QS09YbtTTiSjGcf7NBqtYbrG/BE06zcBPCKEwQGzIv13IVdXNOSub2w==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "ANiqLu3DxW9kol/hMmTWbt3414t9ftdIuiIU7j80okq2YzAueo120M442xk1kDJWtmZTqWQn7wHDvMRipVOEOQ==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "patternkit.core": { + "type": "Project" + }, + "patternkit.examples": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "[9.0.9, )", + "Microsoft.Extensions.Configuration.Binder": "[9.0.9, )", + "Microsoft.Extensions.Options": "[9.0.9, )", + "Microsoft.Extensions.Options.ConfigurationExtensions": "[9.0.9, )", + "Microsoft.Extensions.Options.DataAnnotations": "[9.0.9, )", + "PatternKit.Core": "[1.0.0, )", + "PatternKit.Generators.Abstractions": "[1.0.0, )" + } + }, + "patternkit.generators": { + "type": "Project" + }, + "patternkit.generators.abstractions": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/test/PatternKit.Tests/Behavioral/Chain/ActionChainTests.cs b/test/PatternKit.Tests/Behavioral/Chain/ActionChainTests.cs new file mode 100644 index 0000000..69b2fa0 --- /dev/null +++ b/test/PatternKit.Tests/Behavioral/Chain/ActionChainTests.cs @@ -0,0 +1,239 @@ +using PatternKit.Behavioral.Chain; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Behavioral.Chain; + +[Feature("ActionChain (middleware-style pipeline)")] +public sealed class ActionChainTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + // Context we thread through the chain + private readonly record struct Ctx(List Log, bool Flag = false); + + private sealed record State(ActionChain Chain, List Log); + + // ---------------- Helpers ---------------- + + private static State Build_Order_With_Tail() + { + var log = new List(); + var chain = ActionChain.Create() + .Use(static (in c, next) => + { + c.Log.Add("A"); + next(in c); + }) + .Use(static (in c, next) => + { + c.Log.Add("B"); + next(in c); + }) + .Finally(static (in c, next) => + { + c.Log.Add("TAIL"); + next(in c); // terminal no-op; safe + }) + .Build(); + + return new State(chain, log); + } + + private static State Build_Stop_ShortCircuits() + { + var log = new List(); + var chain = ActionChain.Create() + .Use(static (in c, next) => + { + c.Log.Add("PRE"); + next(in c); + }) + .When(static (in _) => true) + .ThenStop(static c => c.Log.Add("STOP")) + .Use(static (in c, next) => + { + c.Log.Add("POST"); // should NOT run + next(in c); + }) + .Finally(static (in c, next) => + { + c.Log.Add("TAIL"); // should NOT run + next(in c); + }) + .Build(); + + return new State(chain, log); + } + + private static State Build_Continue_Then_Tail() + { + var log = new List(); + var chain = ActionChain.Create() + .When(static (in _) => true) + .ThenContinue(static c => c.Log.Add("CONT")) + .Finally(static (in c, next) => + { + c.Log.Add("TAIL"); + next(in c); + }) + .Build(); + + return new State(chain, log); + } + + private static State Build_When_False_AutoContinue() + { + var log = new List(); + var chain = ActionChain.Create() + .When(static (in c) => c.Flag) // false by default + .ThenStop(static c => c.Log.Add("NEVER")) + .Finally(static (in c, next) => + { + c.Log.Add("TAIL"); + next(in c); + }) + .Build(); + + return new State(chain, log); + } + + private static (ActionChain First, ActionChain Second, List Log) Build_Immutability_TwoChains() + { + var log = new List(); + var b = ActionChain.Create() + .Use(static (in c, next) => + { + c.Log.Add("A"); + next(in c); + }); + + var first = b.Build(); // freezes A only + + // mutate builder after first build + b.Use(static (in c, next) => + { + c.Log.Add("B"); + next(in c); + }) + .Finally(static (in c, next) => + { + c.Log.Add("TAIL"); + next(in c); + }); + + var second = b.Build(); // A then B then TAIL + return (first, second, log); + } + + private static State Build_Handler_ShortCircuits_Skips_Tail() + { + var log = new List(); + var chain = ActionChain.Create() + .Use(static (in c, _) => + { + c.Log.Add("ONLY"); + // do NOT call next → short-circuit + }) + .Finally(static (in c, next) => + { + c.Log.Add("TAIL"); // should NOT run + next(in c); + }) + .Build(); + + return new State(chain, log); + } + + private static State Exec(State s, bool flag = false) + { + var ctx = new Ctx(s.Log, Flag: flag); + s.Chain.Execute(in ctx); + return s; + } + + // ---------------- Scenarios ---------------- + + [Scenario("Order: Use handlers run in registration order; tail runs last when not short-circuited")] + [Fact] + public async Task OrderAndTail() + { + await Given("A, then B, then TAIL", Build_Order_With_Tail) + .When("executing the chain", s => Exec(s)) + .Then("log is A|B|TAIL", s => string.Join('|', s.Log) == "A|B|TAIL") + .AssertPassed(); + } + + [Scenario("ThenStop short-circuits: subsequent handlers and tail do not run")] + [Fact] + public async Task ThenStopShortCircuits() + { + await Given("PRE, ThenStop(true), POST, TAIL", Build_Stop_ShortCircuits) + .When("executing the chain", s => Exec(s)) + .Then("log is PRE|STOP", s => string.Join('|', s.Log) == "PRE|STOP") + .AssertPassed(); + } + + [Scenario("ThenContinue runs action and still proceeds to later handlers/tail")] + [Fact] + public async Task ThenContinueProceeds() + { + await Given("ThenContinue(true), TAIL", Build_Continue_Then_Tail) + .When("executing the chain", s => Exec(s)) + .Then("log is CONT|TAIL", s => string.Join('|', s.Log) == "CONT|TAIL") + .AssertPassed(); + } + + [Scenario("When(false) auto-continues without invoking the guarded action")] + [Fact] + public async Task WhenFalseAutoContinues() + { + await Given("When(c.Flag) ThenStop(NEVER) + TAIL", Build_When_False_AutoContinue) + .When("executing with Flag=false", s => Exec(s, flag: false)) + .Then("log is TAIL", s => string.Join('|', s.Log) == "TAIL") + .AssertPassed(); + } + + [Scenario("Tail is skipped when an earlier handler returns without calling next")] + [Fact] + public async Task TailSkippedOnShortCircuit() + { + await Given("first handler logs and short-circuits; tail exists", Build_Handler_ShortCircuits_Skips_Tail) + .When("executing the chain", s => Exec(s)) + .Then("log is ONLY", s => string.Join('|', s.Log) == "ONLY") + .AssertPassed(); + } + + [Scenario("Built chains are immutable; builder can continue to be mutated to produce new chains")] + [Fact] + public async Task ImmutabilityAfterBuild() + { + await Given("one builder, then build first and mutate for second", Build_Immutability_TwoChains) + .When("executing first chain", t => + { + var (first, _, log) = t; + var ctx = new Ctx(log); + first.Execute(in ctx); + return t; + }) + .And("executing second chain", t => + { + var (_, second, log) = t; + var ctx = new Ctx(log); + second.Execute(in ctx); + return t; + }) + .Then("first execution logged A only", t => + { + var (_, _, log) = t; + // logs so far: first run (A) + return log[0] == "A"; + }) + .And("second execution added A|B|TAIL", t => + { + var (_, _, log) = t; + // full log list: ["A", "A", "B", "TAIL"] + return string.Join('|', log) == "A|A|B|TAIL"; + }) + .AssertPassed(); + } +} \ No newline at end of file diff --git a/test/PatternKit.Tests/Behavioral/Chain/ResultChainTests.cs b/test/PatternKit.Tests/Behavioral/Chain/ResultChainTests.cs new file mode 100644 index 0000000..57a7066 --- /dev/null +++ b/test/PatternKit.Tests/Behavioral/Chain/ResultChainTests.cs @@ -0,0 +1,228 @@ +using PatternKit.Behavioral.Chain; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Behavioral.Chain; + +[Feature("ResultChain (first-match-wins with return value)")] +public sealed class ResultChainTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + // Context threaded through steps so we can accumulate logs and last result + private sealed record Ctx( + ResultChain Chain, + List Log, + bool? Ok = null, + string? Result = null + ); + + // ---------- Helpers ---------- + private static Ctx Build_Defaulted() + { + var log = new List(); + var chain = ResultChain.Create() + .When(static (in i) => i > 0).Then(i => + { + log.Add("pos"); + return $"+{i}"; + }) + .When(static (in i) => i < 0).Then(i => + { + log.Add("neg"); + return i.ToString(); + }) + .Finally(static (in _, out r, _) => + { + // default / fallback + r = "zero"; + return true; + }) + .Build(); + + return new Ctx(chain, log); + } + + private static Ctx Build_NoTail() + { + var log = new List(); + var chain = ResultChain.Create() + .When(static (in i) => i > 0).Then(i => $"+{i}") + .Build(); + return new Ctx(chain, log); + } + + private static Ctx Build_Do_Then_Tail() + { + var log = new List(); + var chain = ResultChain.Create() + // A conditional TryHandler that sometimes produces, sometimes delegates + .When(static (in i) => i % 2 == 0).Do((in i, out r, next) => + { + log.Add("even?"); + if (i != 42) + return next(in i, out r); // delegate to next + r = "forty-two"; + return true; // short-circuit + + }) + // Next even handler will run if previous delegated + .When(static (in i) => i % 2 == 0).Then(_ => + { + log.Add("even"); + return "even"; + }) + // Fallback when nothing produced + .Finally((in _, out r, _) => + { + log.Add("tail"); + r = "odd"; + return true; + }) + .Build(); + + return new Ctx(chain, log); + } + + private static Ctx Exec(Ctx c, int x) + { + var ok = c.Chain.Execute(in x, out var r); + return c with { Ok = ok, Result = r }; + } + + // ---------- Scenarios ---------- + + [Scenario("First match wins; Finally acts as default when nothing matched")] + [Fact] + public async Task FirstMatch_And_Fallback() + { + await Given("a chain with >0, <0, and Finally fallback", Build_Defaulted) + .When("executing with 5", c => Exec(c, 5)) + .Then("returns true and +5", c => c.Ok == true && c.Result == "+5") + .And("log recorded 'pos' and not 'neg'", c => + string.Join("|", c.Log) == "pos") + // run negative + .When("executing with -3", c => + { + c.Log.Clear(); + return Exec(c, -3); + }) + .Then("returns true and -3", c => c.Ok == true && c.Result == "-3") + .And("log recorded 'neg' and not 'pos'", c => + string.Join("|", c.Log) == "neg") + // run zero -> fallback + .When("executing with 0", c => + { + c.Log.Clear(); + return Exec(c, 0); + }) + .Then("returns true and 'zero'", c => c.Ok == true && c.Result == "zero") + .AssertPassed(); + } + + [Scenario("No tail: Execute returns false and result is null when nothing matched")] + [Fact] + public async Task NoTail_NoMatch_ReturnsFalse() + { + await Given("a chain with only >0 branch and no Finally", Build_NoTail) + .When("executing with 0 (no predicate matches)", c => Exec(c, 0)) + .Then("Execute returns false", c => c.Ok == false) + .And("result is null", c => c.Result is null) + .AssertPassed(); + } + + [Scenario("When.Do can produce or delegate; Then handles delegated; Finally covers leftovers")] + [Fact] + public async Task Do_Then_Tail_Composition() + { + await Given("a chain with Do (sometimes produce), Then (even), and tail (odd)", Build_Do_Then_Tail) + .When("executing with 42 (Do produces)", c => Exec(c, 42)) + .Then("returns true and 'forty-two'", c => c.Ok == true && c.Result == "forty-two") + .And("log contains only 'even?'", c => string.Join("|", c.Log) == "even?") + // delegated case + .When("executing with 4 (Do delegates to Then)", c => + { + c.Log.Clear(); + return Exec(c, 4); + }) + .Then("returns true and 'even'", c => c.Ok == true && c.Result == "even") + .And("log contains 'even?|even'", c => string.Join("|", c.Log) == "even?|even") + // odd -> tail + .When("executing with 3 (nothing matched before tail)", c => + { + c.Log.Clear(); + return Exec(c, 3); + }) + .Then("returns true and 'odd'", c => c.Ok == true && c.Result == "odd") + .And("log ends with 'tail'", c => c.Log.LastOrDefault() == "tail") + .AssertPassed(); + } + + [Scenario("Registration order preserved; only first matching producer runs")] + [Fact] + public async Task OrderPreserved_FirstProducerOnly() + { + await Given("a chain with two overlapping predicates in registration order", () => + { + var log = new List(); + var chain = ResultChain.Create() + .When(static (in i) => i >= 0).Then(_ => + { + log.Add("first"); + return "first"; + }) + .When(static (in i) => i >= 0).Then(_ => + { + log.Add("second"); + return "second"; + }) + .Finally((in _, out r, _) => + { + r = "tail"; + return true; + }) + .Build(); + return new Ctx(chain, log); + }) + .When("executing with 2 (both predicates true)", c => Exec(c, 2)) + .Then("result is from the first producer", c => c.Result == "first") + .And("log recorded only 'first'", c => string.Join("|", c.Log) == "first") + .AssertPassed(); + } + + [Scenario("Finally runs only when chain reaches the tail (no earlier producer)")] + [Fact] + public async Task Tail_Runs_Only_When_Not_ShortCircuited() + { + await Given("a chain with a producing head and a logging tail", () => + { + var log = new List(); + var chain = ResultChain.Create() + .When(static (in i) => i > 0).Then(i => + { + log.Add("head"); + return $"+{i}"; + }) + .Finally((in _, out r, _) => + { + log.Add("tail"); + r = "zero-or-neg"; + return true; + }) + .Build(); + return new Ctx(chain, log); + }) + // head produces -> tail should not log + .When("executing with 7", c => Exec(c, 7)) + .Then("result is '+7'", c => c.Result == "+7") + .And("tail did not run", c => !c.Log.Contains("tail")) + // no head match -> tail runs + .When("executing with 0", c => + { + c.Log.Clear(); + return Exec(c, 0); + }) + .Then("result is from tail", c => c.Result == "zero-or-neg") + .And("tail logged", c => c.Log.Contains("tail")) + .AssertPassed(); + } +} \ No newline at end of file diff --git a/test/PatternKit.Tests/Behavioral/Strategy/ActionStrategyTests.cs b/test/PatternKit.Tests/Behavioral/Strategy/ActionStrategyTests.cs new file mode 100644 index 0000000..9f60f83 --- /dev/null +++ b/test/PatternKit.Tests/Behavioral/Strategy/ActionStrategyTests.cs @@ -0,0 +1,141 @@ +using PatternKit.Behavioral.Strategy; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Behavioral.Strategy; + +[Feature("ActionStrategy (first-match-wins action pipeline)")] +public sealed class ActionStrategyTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + // Pipe this context through steps + private sealed record Ctx(ActionStrategy S, List Log, bool? Ok = null, Exception? Ex = null); + + // --- Helpers to keep steps tiny --- + private static Ctx Build_Defaulted() + { + var log = new List(); + var s = ActionStrategy.Create() + .When((in i) => i > 0).Then((in i) => log.Add($"+{i}")) + .When((in i) => i < 0).Then((in i) => log.Add($"{i}")) + .Default((in _) => log.Add("zero")) + .Build(); + return new Ctx(s, log); + } + + private static Ctx Build_NoDefault() + { + var log = new List(); + var s = ActionStrategy.Create() + .When((in i) => i > 0).Then((in i) => log.Add($"+{i}")) + .When((in i) => i < 0).Then((in i) => log.Add($"{i}")) + .Build(); + return new Ctx(s, log); + } + + private static Ctx Exec(Ctx c, int x) + { + c.S.Execute(in x); + return c; + } + + private static Ctx TryExec(Ctx c, int x) => c with { Ok = c.S.TryExecute(in x) }; + + private static Ctx ExecCatch(Ctx c, int x) + { + try + { + c.S.Execute(in x); + } + catch (Exception ex) + { + return c with { Ex = ex }; + } + + return c; + } + + // --------------------------------------------------------------------- + + [Scenario("First matching branch runs; later matches are ignored; default runs when none match")] + [Fact] + public async Task FirstMatchAndDefault() + { + await Given("a strategy with >0, <0, and default branches", Build_Defaulted) + .When("executing with 5", c => Exec(c, 5)) + .And("executing with -3", c => Exec(c, -3)) + .But("executing with 0", c => Exec(c, 0)) + .Then("should have logged +5, -3, zero in order", + c => string.Join("|", c.Log) == "+5|-3|zero") + .AssertPassed(); + } + + [Scenario("Execute throws when nothing matches and no default is configured")] + [Fact] + public async Task ExecuteThrowsWithoutDefault() + { + await Given("a strategy without a default branch", Build_NoDefault) + .When("executing with 0 (no predicates match)", c => ExecCatch(c, 0)) + .Then("should capture InvalidOperationException", + c => c.Ex is InvalidOperationException) + .And("should not have logged anything", + c => c.Log.Count == 0) + .AssertPassed(); + } + + [Scenario("TryExecute returns true when a branch matches")] + [Fact] + public async Task TryExecuteTrueWhenMatched() + { + await Given("a strategy that logs even numbers", () => + { + var log = new List(); + var s = ActionStrategy.Create() + .When((in i) => i % 2 == 0).Then((in i) => log.Add($"even:{i}")) + .Build(); + return new Ctx(s, log); + }) + .When("TryExecute(4)", c => TryExec(c, 4)) + .Then("should return true", c => c.Ok == true) + .And("should log even:4", c => string.Join("|", c.Log) == "even:4") + .AssertPassed(); + } + + [Scenario("TryExecute returns true when only default ran; false when no default and no match")] + [Fact] + public async Task TryExecuteDefaultVsNoDefault() + { + // With default → true + fallback log + await Given("a strategy with a default action", Build_Defaulted) + .When("TryExecute(0) where no predicate matches", c => TryExec(c, 0)) + .Then("should return true", c => c.Ok == true) + .And("should log zero", c => string.Join("|", c.Log) == "zero") + .AssertPassed(); + + // Without default → false + no logs + await Given("a strategy without default", Build_NoDefault) + .When("TryExecute(0) where no predicate matches", c => TryExec(c, 0)) + .Then("should return false", c => c.Ok == false) + .And("should log nothing", c => c.Log.Count == 0) + .AssertPassed(); + } + + [Scenario("Registration order is preserved; only the first matching action runs")] + [Fact] + public async Task OrderPreserved_FirstMatchOnly() + { + await Given("a strategy with overlapping predicates in order", () => + { + var log = new List(); + var s = ActionStrategy.Create() + .When((in i) => i % 2 == 0).Then((in _) => log.Add("first")) + .When((in i) => i >= 0).Then((in _) => log.Add("second")) + .Default((in _) => log.Add("default")) + .Build(); + return new Ctx(s, log); + }) + .When("executing with 2 (both predicates true)", c => Exec(c, 2)) + .Then("should only run the first action", c => string.Join("|", c.Log) == "first") + .AssertPassed(); + } +} \ No newline at end of file diff --git a/test/PatternKit.Tests/Behavioral/Strategy/AsyncStrategyTests.cs b/test/PatternKit.Tests/Behavioral/Strategy/AsyncStrategyTests.cs new file mode 100644 index 0000000..b945240 --- /dev/null +++ b/test/PatternKit.Tests/Behavioral/Strategy/AsyncStrategyTests.cs @@ -0,0 +1,195 @@ +using PatternKit.Behavioral.Strategy; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Behavioral.Strategy; + +[Feature("AsyncStrategy (first-match-wins async strategy)")] +public sealed class AsyncStrategyTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + // Scenario context + private sealed record Ctx( + AsyncStrategy S, + List Log, + string? Result = null, + Exception? Ex = null + ); + + // ---------- Helpers ---------- + private static Ctx Build_Defaulted() + { + var log = new List(); + var s = AsyncStrategy.Create() + .When(static (n, _) => new ValueTask(n > 0)) + .Then((n, _) => + { + log.Add("pos"); + return new ValueTask("+" + n); + }) + .When(static (n, _) => new ValueTask(n < 0)) + .Then((n, _) => + { + log.Add("neg"); + return new ValueTask(n.ToString()); + }) + .Default(static (_, _) => new ValueTask("zero")) + .Build(); + + return new Ctx(s, log); + } + + private static Ctx Build_NoDefault() + { + var log = new List(); + var s = AsyncStrategy.Create() + .When(static (n, _) => new ValueTask(n > 0)) + .Then((n, _) => + { + log.Add("pos"); + return new ValueTask("+" + n); + }) + .Build(); + + return new Ctx(s, log); + } + + private static Ctx Build_SyncAdapters() + { + // Mix synchronous adapters for predicate and default + var s = AsyncStrategy.Create() + .When(static n => n % 2 == 0) + .Then(static (_, _) => new ValueTask("even")) + .When(static (n, _) => new ValueTask(n >= 0)) + .Then(static (_, _) => new ValueTask("nonneg")) + .Default(static _ => "other") + .Build(); + + return new Ctx(s, new List()); + } + + private static Ctx Build_OrderLog() + { + var log = new List(); + var s = AsyncStrategy.Create() + .When(static (n, _) => new ValueTask(n >= 0)) + .Then((_, _) => + { + log.Add("first"); + return new ValueTask("first"); + }) + .When(static (n, _) => new ValueTask(n >= 0)) + .Then((_, _) => + { + log.Add("second"); + return new ValueTask("second"); + }) + .Default(static (_, _) => new ValueTask("default")) + .Build(); + + return new Ctx(s, log); + } + + private static Ctx Build_Cancellable() + { + var s = AsyncStrategy.Create() + .When(static (_, ct) => + { + ct.ThrowIfCancellationRequested(); + return new ValueTask(true); + }) + .Then(static (_, ct) => + { + ct.ThrowIfCancellationRequested(); + return new ValueTask("ok"); + }) + .Build(); + + return new Ctx(s, new List()); + } + + private static async Task ExecAsync(Ctx c, int n, CancellationToken ct = default) + { + var r = await c.S.ExecuteAsync(n, ct); + return c with { Result = r, Ex = null }; + } + + private static async Task ExecCatchAsync(Ctx c, int n, CancellationToken ct = default) + { + try + { + var r = await c.S.ExecuteAsync(n, ct); + return c with { Result = r, Ex = null }; + } + catch (Exception ex) + { + return c with { Ex = ex }; + } + } + + // ---------- Scenarios ---------- + + [Scenario("First matching async branch runs; default used when none match")] + [Fact] + public async Task FirstMatchAndDefault() + { + await Given("a strategy with >0, <0, and default branches (async)", Build_Defaulted) + .When("executing with 5", c => ExecAsync(c, 5)) + .Then("returns +5 and logs 'pos'", c => c.Result == "+5" && string.Join("|", c.Log) == "pos") + .When("executing with -3", c => { c.Log.Clear(); return ExecAsync(c, -3); }) + .Then("returns -3 and logs 'neg'", c => c.Result == "-3" && string.Join("|", c.Log) == "neg") + .When("executing with 0", c => { c.Log.Clear(); return ExecAsync(c, 0); }) + .Then("returns 'zero' and no branch logs", c => c.Result == "zero" && c.Log.Count == 0) + .AssertPassed(); + } + + [Scenario("ExecuteAsync throws when nothing matches and no default is configured")] + [Fact] + public async Task ThrowsWithoutDefault() + { + await Given("a strategy without a default branch", Build_NoDefault) + .When("executing with 0 (no predicate matches)", c => ExecCatchAsync(c, 0)) + .Then("captures InvalidOperationException", c => c.Ex is InvalidOperationException) + .AssertPassed(); + } + + [Scenario("Synchronous adapters (When/Default) behave correctly")] + [Fact] + public async Task SyncAdaptersWork() + { + await Given("a strategy using sync adapters", Build_SyncAdapters) + .When("executing with 2", c => ExecAsync(c, 2)) + .Then("returns 'even'", c => c.Result == "even") + .When("executing with 1", c => ExecAsync(c, 1)) + .Then("returns 'nonneg'", c => c.Result == "nonneg") + .When("executing with -1", c => ExecAsync(c, -1)) + .Then("returns 'other' via sync default", c => c.Result == "other") + .AssertPassed(); + } + + [Scenario("Registration order preserved; only first matching handler runs")] + [Fact] + public async Task OrderPreserved_FirstMatchOnly() + { + await Given("a strategy with overlapping predicates in order", Build_OrderLog) + .When("executing with 7 (both predicates true)", c => ExecAsync(c, 7)) + .Then("result comes from the first", c => c.Result == "first") + .And("only 'first' logged", c => string.Join("|", c.Log) == "first") + .AssertPassed(); + } + + [Scenario("Cancellation token is honored by predicates/handlers")] + [Fact] + public async Task CancellationPropagates() + { + await Given("a strategy whose predicate/handler check the token", Build_Cancellable) + .When("executing with a cancelled token", c => + { + var cts = new CancellationTokenSource(); + cts.Cancel(); + return ExecCatchAsync(c, 1, cts.Token); + }) + .Then("captures OperationCanceledException", c => c.Ex is OperationCanceledException) + .AssertPassed(); + } +} diff --git a/test/PatternKit.Tests/Core/Behavioral/Strategy/CoercerTryStrategyTests.cs b/test/PatternKit.Tests/Behavioral/Strategy/CoercerTryStrategyTests.cs similarity index 90% rename from test/PatternKit.Tests/Core/Behavioral/Strategy/CoercerTryStrategyTests.cs rename to test/PatternKit.Tests/Behavioral/Strategy/CoercerTryStrategyTests.cs index 97a0ef1..af78bce 100644 --- a/test/PatternKit.Tests/Core/Behavioral/Strategy/CoercerTryStrategyTests.cs +++ b/test/PatternKit.Tests/Behavioral/Strategy/CoercerTryStrategyTests.cs @@ -4,7 +4,7 @@ using TinyBDD.Xunit; using Xunit.Abstractions; -namespace PatternKit.Tests.Core.Behavioral.Strategy; +namespace PatternKit.Tests.Behavioral.Strategy; [Feature("Coercer (JsonElement -> primitives)")] public class CoercerTryStrategyTests(ITestOutputHelper output) : TinyBddXunitBase(output) @@ -47,18 +47,19 @@ public async Task CoerceJsonElement() { await Given("a TryStrategy coercer", BuildCoercer) .When("coercing 123", s => Execute(s, JsonDocument.Parse("123").RootElement)) - .Then("should return 123 (int)", v => v is int i && i == 123) + .Then("should return 123 (int)", v => v is 123) .AssertPassed(); await Given("same coercer", BuildCoercer) .When("coercing true", s => Execute(s, JsonDocument.Parse("true").RootElement)) - .Then("should return true (bool)", v => v is bool b && b) + .Then("should return true (bool)", v => v is true) .AssertPassed(); await Given("same coercer", BuildCoercer) .When("coercing \"hello\"", s => Execute(s, JsonDocument.Parse("\"hello\"").RootElement)) - .Then("should return \"hello\" (string)", v => v is string s2 && s2 == "hello") + .Then("should return \"hello\" (string)", v => v is "hello") .AssertPassed(); + return; static TryStrategy BuildCoercer() => TryStrategy.Create() diff --git a/test/PatternKit.Tests/Core/Behavioral/Strategy/SelectorStrategyTests.cs b/test/PatternKit.Tests/Behavioral/Strategy/SelectorStrategyTests.cs similarity index 97% rename from test/PatternKit.Tests/Core/Behavioral/Strategy/SelectorStrategyTests.cs rename to test/PatternKit.Tests/Behavioral/Strategy/SelectorStrategyTests.cs index 134d9a1..6c3c261 100644 --- a/test/PatternKit.Tests/Core/Behavioral/Strategy/SelectorStrategyTests.cs +++ b/test/PatternKit.Tests/Behavioral/Strategy/SelectorStrategyTests.cs @@ -3,7 +3,8 @@ using TinyBDD.Xunit; using Xunit.Abstractions; -namespace PatternKit.Tests.Core.Behavioral.Strategy; +namespace PatternKit.Tests.Behavioral.Strategy; + [Feature("Strategy (Selector)")] public class SelectorStrategyTests(ITestOutputHelper output) : TinyBddXunitBase(output) diff --git a/test/PatternKit.Tests/Behavioral/Strategy/StrategyTests.cs b/test/PatternKit.Tests/Behavioral/Strategy/StrategyTests.cs new file mode 100644 index 0000000..69d9103 --- /dev/null +++ b/test/PatternKit.Tests/Behavioral/Strategy/StrategyTests.cs @@ -0,0 +1,138 @@ +using PatternKit.Behavioral.Strategy; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Behavioral.Strategy; + +[Feature("Strategy (first-match-wins, returns value)")] +public sealed class StrategyTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + // ---------- Shared predicate/handler method pointers ---------- + private static bool IsPositive(in int x) => x > 0; + private static bool IsNegative(in int x) => x < 0; + private static bool IsNonNegative(in int x) => x >= 0; + private static bool IsEven(in int x) => (x & 1) == 0; + + private static string LabelPositive(in int x) => $"pos:{x}"; + private static string LabelNegative(in int x) => $"neg:{x}"; + private static string LabelFirst (in int _) => "first"; + private static string LabelSecond (in int _) => "second"; + private static string LabelZero (in int _) => "zero"; + private static string LabelOther (in int _) => "other"; + private static string LabelEven (in int _) => "even"; + + // Context keeps the strategy + last result/exception so TinyBDD type stays stable + private sealed record Ctx(Strategy S, string? Last = null, Exception? Ex = null); + + // ---------- Builders ---------- + private static Ctx Build_PosNeg_DefaultZero() + => new( + Strategy.Create() + .When(IsPositive).Then(LabelPositive) + .When(IsNegative).Then(LabelNegative) + .Default(LabelZero) + .Build()); + + private static Ctx Build_PosOnly_NoDefault() + => new( + Strategy.Create() + .When(IsPositive).Then(LabelPositive) + .Build()); + + private static Ctx Build_Order_NonNeg_Then_Even() + => new( + Strategy.Create() + .When(IsNonNegative).Then(LabelFirst) + .When(IsEven).Then(LabelSecond) + .Default(LabelOther) + .Build()); + + private static Ctx Build_Order_Even_Then_NonNeg() + => new( + Strategy.Create() + .When(IsEven).Then(LabelFirst) + .When(IsNonNegative).Then(LabelSecond) + .Default(LabelOther) + .Build()); + + private static Ctx Build_Even_With_DefaultOther() + => new( + Strategy.Create() + .When(IsEven).Then(LabelEven) + .Default(LabelOther) + .Build()); + + // ---------- Helpers ---------- + private static Ctx Exec(Ctx c, int x) + { + var v = x; // need a variable for 'in' + var r = c.S.Execute(in v); + return c with { Last = r, Ex = null }; + } + + private static Ctx ExecCatch(Ctx c, int x) + { + try + { + var v = x; // need a variable for 'in' + var r = c.S.Execute(in v); + return c with { Last = r, Ex = null }; + } + catch (Exception ex) + { + return c with { Ex = ex }; + } + } + + // ---------- Scenarios ---------- + + [Scenario("First matching branch wins; default runs when none match")] + [Fact] + public async Task FirstMatch_And_Default() + { + await Given("a strategy with >0, <0, and default 'zero'", Build_PosNeg_DefaultZero) + .When("executing with 5", c => Exec(c, 5)) + .Then("returns 'pos:5'", c => c.Last == "pos:5") + .When("executing with -3", c => Exec(c, -3)) + .Then("returns 'neg:-3'", c => c.Last == "neg:-3") + .When("executing with 0", c => Exec(c, 0)) + .Then("returns 'zero'", c => c.Last == "zero") + .AssertPassed(); + } + + [Scenario("Execute throws when nothing matches and no default is configured")] + [Fact] + public async Task Throws_Without_Default() + { + await Given("a strategy with only >0 branch (no default)", Build_PosOnly_NoDefault) + .When("executing with 0", c => ExecCatch(c, 0)) + .Then("captures InvalidOperationException", c => c.Ex is InvalidOperationException) + .AssertPassed(); + } + + [Scenario("Registration order is preserved; only the first matching handler executes")] + [Fact] + public async Task Order_Preserved_First_Match_Only() + { + await Given("NonNegative before Even", Build_Order_NonNeg_Then_Even) + .When("executing with 2 (matches both)", c => Exec(c, 2)) + .Then("result is from the first branch", c => c.Last == "first") + .AssertPassed(); + + await Given("Even before NonNegative", Build_Order_Even_Then_NonNeg) + .When("executing with 2 (matches both)", c => Exec(c, 2)) + .Then("result is from the first (even) branch", c => c.Last == "first") + .AssertPassed(); + } + + [Scenario("Default returns a value when all predicates fail")] + [Fact] + public async Task Default_Returns_Value() + { + await Given("a strategy with only Even branch plus default 'other'", Build_Even_With_DefaultOther) + .When("executing with 3 (no predicate match)", c => Exec(c, 3)) + .Then("returns 'other'", c => c.Last == "other") + .AssertPassed(); + } +} diff --git a/test/PatternKit.Tests/Core/Behavioral/Strategy/StrategyTests.cs b/test/PatternKit.Tests/Behavioral/Strategy/TryStrategyTests.cs similarity index 96% rename from test/PatternKit.Tests/Core/Behavioral/Strategy/StrategyTests.cs rename to test/PatternKit.Tests/Behavioral/Strategy/TryStrategyTests.cs index 7e7430e..11b4616 100644 --- a/test/PatternKit.Tests/Core/Behavioral/Strategy/StrategyTests.cs +++ b/test/PatternKit.Tests/Behavioral/Strategy/TryStrategyTests.cs @@ -3,9 +3,9 @@ using TinyBDD.Xunit; using Xunit.Abstractions; -namespace PatternKit.Tests.Core.Behavioral.Strategy; +namespace PatternKit.Tests.Behavioral.Strategy; -[Feature("Strategy (Try)")] +[Feature("TryStrategy (first-match wins without exceptions)")] public class TryStrategyTests(ITestOutputHelper output) : TinyBddXunitBase(output) { private readonly record struct TryResult(bool Matched, TOut? Value); @@ -94,6 +94,7 @@ await Given("a flag that disables NEGATIVE handler", () => false) .And("classifying -2", s => Execute(s, -2)) .Then("should fall through to fallback 'other'", r => r is { Matched: true, Value: "other" }) .AssertPassed(); + return; static TryStrategy BuildWithFlag(bool includeNegative) => TryStrategy.Create() @@ -114,6 +115,7 @@ await Given("a TryStrategy without fallback (only matches even)", BuildNoFallbac .When("classifying 7", s => Execute(s, 7)) .Then("should not match", r => r is { Matched: false, Value: null }) .AssertPassed(); + return; static TryStrategy BuildNoFallback() => TryStrategy.Create() @@ -138,5 +140,7 @@ public Task NoHandlerMatchesNoDefault_Throws() .When(IsNull).Then(HandleNull) .Build(); } - -} \ No newline at end of file + +} + + diff --git a/test/PatternKit.Tests/Creational/Builder/BranchBuilderTests.cs b/test/PatternKit.Tests/Creational/Builder/BranchBuilderTests.cs new file mode 100644 index 0000000..5cff75b --- /dev/null +++ b/test/PatternKit.Tests/Creational/Builder/BranchBuilderTests.cs @@ -0,0 +1,151 @@ +using PatternKit.Creational.Builder; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Creational.Builder; + +[Feature("BranchBuilder (collect pairs + optional default, project to product)")] +public sealed class BranchBuilderTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + // Concrete delegate shapes for the tests + private delegate bool Pred(in int x); + private delegate string Handler(in int x); + + // Handlers / predicates (method pointers) + private static bool IsEven(in int x) => (x & 1) == 0; + private static bool IsPositive(in int x) => x > 0; + private static string HandleEven(in int _) => "even"; + private static string HandlePositive(in int _) => "pos"; + private static string HandleDefaultA(in int _) => "defA"; + private static string HandleDefaultB(in int _) => "defB"; + private static string Fallback(in int _) => "fallback"; + + // Product projected by BranchBuilder.Build + private sealed record Product(Pred[] Preds, Handler[] Handlers, bool HasDefault, Handler Default); + + private static Product BuildProduct(BranchBuilder b, Handler? configuredDefault = null) + { + if (configuredDefault is not null) + b.Default(configuredDefault); + + return b.Build( + fallbackDefault: Fallback, + projector: static (p, h, hasDef, def) => new Product(p, h, hasDef, def)); + } + + // ---------------- Scenarios ---------------- + + [Scenario("Add preserves order; fallback default is used when none was configured")] + [Fact] + public async Task Order_And_Fallback() + { + await Given("a builder with two predicate/handler pairs", () => + { + var b = BranchBuilder.Create() + .Add(IsEven, HandleEven) + .Add(IsPositive, HandlePositive); + return BuildProduct(b); // no explicit Default + }) + .Then("should have 2 predicates and 2 handlers in registration order", p => + { + Assert.Equal(2, p.Preds.Length); + Assert.Equal(2, p.Handlers.Length); + + // Invoke to prove order: first is IsEven -> true on 2, second IsPositive -> true on 1 + var v2 = 2; var v1 = 1; + Assert.True(p.Preds[0](in v2)); + Assert.True(p.Preds[1](in v1)); + + // Handlers return expected labels + Assert.Equal("even", p.Handlers[0](in v2)); + Assert.Equal("pos", p.Handlers[1](in v1)); + return true; + }) + .And("HasDefault=false and Default==fallback", p => + p.HasDefault == false && ReferenceEquals(p.Default, (Handler)Fallback)) + .AssertPassed(); + } + + [Scenario("Explicit Default is passed through and HasDefault=true")] + [Fact] + public async Task Explicit_Default_Wins() + { + await Given("a builder with pairs and an explicit default", () => + { + var b = BranchBuilder.Create() + .Add(IsEven, HandleEven) + .Add(IsPositive, HandlePositive); + return BuildProduct(b, HandleDefaultA); + }) + .Then("HasDefault=true and Default==configured A (not fallback)", p => + p.HasDefault == true && ReferenceEquals(p.Default, (Handler)HandleDefaultA)) + .AssertPassed(); + } + + [Scenario("Default can be replaced; last one wins")] + [Fact] + public async Task Default_Replacement_Last_Wins() + { + await Given("a builder with two Default() calls", () => + { + var b = BranchBuilder.Create() + .Add(IsEven, HandleEven) + .Default(HandleDefaultA) + .Default(HandleDefaultB); // replace + return BuildProduct(b); + }) + .Then("HasDefault=true and Default==B", p => + p.HasDefault == true && ReferenceEquals(p.Default, (Handler)HandleDefaultB)) + .AssertPassed(); + } + + [Scenario("Build supports empty set of pairs")] + [Fact] + public async Task Build_Empty_Is_Allowed() + { + await Given("an empty builder (no pairs, no default)", () => + { + var b = BranchBuilder.Create(); + return BuildProduct(b); + }) + .Then("arrays are empty; HasDefault=false; Default==fallback", p => + p.Preds.Length == 0 && + p.Handlers.Length == 0 && + p.HasDefault == false && + ReferenceEquals(p.Default, (Handler)Fallback)) + .AssertPassed(); + } + + [Scenario("Build snapshots are immutable: later Add() does not mutate earlier product arrays")] + [Fact] + public async Task Build_Snapshot_Immutability() + { + await Given("a builder; build P1, then add another pair and build P2", () => + { + var b = BranchBuilder.Create() + .Add(IsEven, HandleEven); + + var p1 = BuildProduct(b); // snapshot of one pair + + b.Add(IsPositive, HandlePositive); + var p2 = BuildProduct(b); // snapshot of two pairs + + return (P1: p1, P2: p2); + }) + .Then("P1 has 1 pair; P2 has 2 pairs", t => + { + Assert.Equal(1, t.P1.Preds.Length); + Assert.Equal(1, t.P1.Handlers.Length); + Assert.Equal(2, t.P2.Preds.Length); + Assert.Equal(2, t.P2.Handlers.Length); + + // Prove P1 refers to its own array instance (not resized/mutated): + var v2 = 2; + Assert.True(t.P1.Preds[0](in v2)); + Assert.Equal("even", t.P1.Handlers[0](in v2)); + return true; + }) + .AssertPassed(); + } +} diff --git a/test/PatternKit.Tests/Creational/Builder/ChainBuilderTests.cs b/test/PatternKit.Tests/Creational/Builder/ChainBuilderTests.cs new file mode 100644 index 0000000..b2a235d --- /dev/null +++ b/test/PatternKit.Tests/Creational/Builder/ChainBuilderTests.cs @@ -0,0 +1,77 @@ +using PatternKit.Creational.Builder; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Creational.Builder; + +[Feature("ChainBuilder")] +public sealed class ChainBuilderTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + private sealed record Ctx(ChainBuilder B, object? Result = null); + + private static Ctx Build_Empty() => new(ChainBuilder.Create()); + + private static Ctx Build_With_TwoItems() => new( + ChainBuilder.Create() + .Add(1) + .Add(2) + ); + + private static Ctx Build_With_ThreeItems() => new( + ChainBuilder.Create() + .Add(1) + .Add(2) + .Add(3) + ); + + private static Ctx Build_With_AddIf() => new( + ChainBuilder.Create() + .AddIf(true, 42) + .AddIf(false, 99) + ); + + [Scenario("Create returns an empty builder")] + [Fact] + public async Task Create_ShouldReturnEmptyBuilder() + { + await Given("an empty builder", Build_Empty) + .When("building to get length", c => c with { Result = c.B.Build(arr => arr.Length) }) + .Then("result length is 0", c => (int)c.Result! == 0) + .AssertPassed(); + } + + [Scenario("Add registers items in order")] + [Fact] + public async Task Add_ShouldAddItems() + { + await Given("a builder with two items", Build_With_TwoItems) + .When("projecting to CSV", c => c with { Result = c.B.Build(arr => string.Join(",", arr)) }) + .Then("CSV matches \"1,2\"", c => (string)c.Result! == "1,2") + .AssertPassed(); + } + + [Scenario("AddIf only adds when condition is true")] + [Fact] + public async Task AddIf_ShouldAddItem_WhenConditionIsTrue() + { + await Given("a builder using AddIf(true) and AddIf(false)", Build_With_AddIf) + .When("building to array", c => c with { Result = c.B.Build(arr => arr) }) + .Then("only the true branch item exists", c => + { + var arr = (int[])c.Result!; + return arr.Length == 1 && arr[0] == 42; + }) + .AssertPassed(); + } + + [Scenario("Build can project items (aggregation)")] + [Fact] + public async Task Build_ShouldProjectItems() + { + await Given("a builder with 1,2,3", Build_With_ThreeItems) + .When("building to sum", c => c with { Result = c.B.Build(arr => arr.Sum()) }) + .Then("sum equals 6", c => (int)c.Result! == 6) + .AssertPassed(); + } +} diff --git a/test/PatternKit.Tests/Creational/Builder/ComposerTests.cs b/test/PatternKit.Tests/Creational/Builder/ComposerTests.cs new file mode 100644 index 0000000..e643f54 --- /dev/null +++ b/test/PatternKit.Tests/Creational/Builder/ComposerTests.cs @@ -0,0 +1,142 @@ +using PatternKit.Creational.Builder; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Creational.Builder; + +[Feature("Creational - Composer")] +public sealed class ComposerTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + // ----------- Test state & DTO ----------- + private readonly record struct PersonState(string? Name, int Age); + + private sealed record PersonDto(string Name, int Age); + + // ----------- Seed factories ----------- + private static PersonState SeedDefault() => default; // (null, 0) + private static PersonState SeedAda30() => new("Ada", 30); + private static PersonState SeedSeeded() => new("Seed", 10); + + // ----------- Transformations (prefer functions over lambdas) ----------- + private static PersonState SetNameAda(PersonState s) => s with { Name = "Ada" }; + private static PersonState SetAge30(PersonState s) => s with { Age = 30 }; + private static PersonState SetAge(PersonState s, int age) => s with { Age = age }; + private static PersonState SetName(PersonState s, string? name) => s with { Name = name }; + + // ----------- Validations ----------- + private static string? ValidateNameRequired(PersonState s) + => string.IsNullOrWhiteSpace(s.Name) ? "Name is required." : null; + + private static string? ValidateAge0To130(PersonState s) + => s.Age is < 0 or > 130 ? $"Age must be within [0, 130] but was {s.Age}." : null; + + private static string? AlwaysOk(PersonState _) => null; + + // ----------- Projection ----------- + private static PersonDto Project(PersonState s) => new(s.Name!, s.Age); + + // ---------- Helpers ---------- + private static Composer NewComposer(Func seed) + => Composer.New(seed); + + // ============================================================ + // Tests + // ============================================================ + + [Scenario("Transformations are applied in order and projection produces the final DTO")] + [Fact] + public Task Compose_Applies_Transforms_In_Order_And_Projects() + => Given("a composer seeded with default state", () => NewComposer(SeedDefault)) + .When("adding SetNameAda then SetAge30 and a pass validation", c => + c.With(SetNameAda) + .With(SetAge30) + .Require(AlwaysOk)) + .And("building to DTO", c => c.Build(Project)) + .Then("DTO has Name='Ada' and Age=30", dto => dto is { Name: "Ada", Age: 30 }) + .AssertPassed(); + + [Scenario("Validation failure throws with message")] + [Fact] + public Task Require_Throws_On_Validation_Failure() + => Given("a composer seeded with default state", () => NewComposer(SeedDefault)) + .When("adding only age but missing name, and requiring a non-empty name", c => + c.With(SetAge30).Require(ValidateNameRequired)) + .And("building to DTO (should throw)", c => Record.Exception(() => c.Build(Project))) + .Then("throws InvalidOperationException with 'Name is required.'", + ex => ex is InvalidOperationException { Message: "Name is required." }) + .AssertPassed(); + + [Scenario("Multiple validations - first failure message is thrown")] + [Fact] + public async Task Multiple_Validations_First_Failure_Message() + { + await Given("a composer seeded with Ada/30", () => NewComposer(SeedAda30)) + .When("adding two validators that both fail", c => + c.Require(FirstFailure).Require(SecondFailure)) + .And("building to DTO", c => Record.Exception(() => c.Build(Project))) + .Then("the first validator's message is thrown", + ex => ex is InvalidOperationException { Message: "boom 1" }) + .AssertPassed(); + return; + + static string SecondFailure(PersonState s) => "boom 2"; + static string FirstFailure(PersonState s) => "boom 1"; + } + + [Scenario("No transformations uses the seed state")] + [Fact] + public Task No_Transforms_Uses_Seed() + => Given("a composer seeded with Name='Seed', Age=10", () => NewComposer(SeedSeeded)) + .When("adding a pass validation only", c => c.Require(AlwaysOk)) + .And("building to DTO", c => c.Build(Project)) + .Then("DTO reflects the seed values", dto => dto is { Name: "Seed", Age: 10 }) + .AssertPassed(); + + [Scenario("Composer can be reused; subsequent builds reflect additional With steps")] + [Fact] + public Task Composer_Can_Be_Reused() + => Given("a composer seeded with default", () => NewComposer(SeedDefault)) + .When("adding SetNameAda", c => c.With(SetNameAda)) + .And("building first DTO", c => (composer: c, first: c.Build(Project))) + .And("adding SetAge30", t => + { + t.composer.With(SetAge30); + return t; + }) + .And("building second DTO", t => (t.first, second: t.composer.Build(Project))) + .Then("first DTO only has the first transform applied", + t => t.first is { Name: "Ada", Age: 0 }) + .And("second DTO has both transforms applied", + t => t.second is { Name: "Ada", Age: 30 }) + .AssertPassed(); + + [Scenario("Validation of age range with transformation in pipeline")] + [Fact] + public Task Age_Range_Validation_Works() + => Given("a composer seeded with default", () => NewComposer(SeedDefault)) + .When("setting name and invalid age, then requiring valid age range", c => + c.With(static s => SetName(s, "Bob")) + .With(static s => SetAge(s, -5)) + .Require(ValidateAge0To130)) + .And("building to DTO", c => Record.Exception(() => c.Build(Project))) + .Then("should throw with range message", + ex => ex is InvalidOperationException { Message: "Age must be within [0, 130] but was -5." }) + .AssertPassed(); + + [Scenario("Transformation composition is left-to-right (b(a(seed)))")] + [Fact] + public async Task Composition_Is_Left_To_Right() + { + await Given("a composer seeded with default", () => NewComposer(SeedDefault)) + .When("adding A then B with pass validation", c => c.With(A).With(B).Require(AlwaysOk)) + .And("building", c => c.Build(Project)) + .Then("age should be 20 (B after A)", dto => dto.Age == 20) + .AssertPassed(); + return; + + // a: set age to 10, b: set age to 20. b should win if composed as b(a(seed)). + static PersonState A(PersonState s) => SetAge(s, 10); + static PersonState B(PersonState s) => SetAge(s, 20); + } +} \ No newline at end of file diff --git a/test/PatternKit.Tests/Creational/Builder/MutableBuilderTests.cs b/test/PatternKit.Tests/Creational/Builder/MutableBuilderTests.cs new file mode 100644 index 0000000..10e5a48 --- /dev/null +++ b/test/PatternKit.Tests/Creational/Builder/MutableBuilderTests.cs @@ -0,0 +1,138 @@ +using PatternKit.Creational.Builder; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Creational.Builder; + +[Feature("Creational - MutableBuilder")] +public sealed class MutableBuilderTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + // ---------- Test model ---------- + private sealed class Person + { + public string? Name { get; set; } + public int Age { get; set; } + public List Steps { get; } = []; + } + + // ---------- Factories ---------- + private static Person NewPerson() => new(); + private static MutableBuilder NewBuilder() => MutableBuilder.New(static () => NewPerson()); + + // ---------- Mutations ---------- + private static void SetNameAda(Person p) => p.Name = "Ada"; + private static void SetNameEmpty(Person p) => p.Name = ""; + private static void SetAge30(Person p) => p.Age = 30; + private static void SetAgeNeg1(Person p) => p.Age = -1; + private static void AppendStepA(Person p) => p.Steps.Add("A"); + private static void AppendStepB(Person p) => p.Steps.Add("B"); + + // ---------- Validations ---------- + private static string? ValidateOk(Person _) => null; + + // ---------- Helpers ---------- + private static Person BuildPerson(MutableBuilder b) => b.Build(); + + [Scenario("Build applies mutations and passes validations")] + [Fact] + public async Task Build_Succeeds_WithMutations_AndValidations() + { + await Given("a fresh builder", NewBuilder) + .When("configuring name, age, and a pass-through validator", + b => b.With(SetNameAda) + .With(SetAge30) + .Require(ValidateOk)) + .And("building the person", BuildPerson) + .Then("the result should contain applied values", + p => p is { Name: "Ada", Age: 30 }) + .AssertPassed(); + } + + [Scenario("First failing validation throws with its message")] + [Fact] + public async Task Build_Throws_OnFirstValidationFailure() + { + await Given("a builder with an empty name and a non-empty requirement", NewBuilder) + .When("configuring invalid name and RequireNotEmpty(Name)", + b => b.With(SetNameEmpty) + .RequireNotEmpty(static p => p.Name, nameof(Person.Name))) + .And("attempting to build", b => Record.Exception(() => b.Build())) + .Then("should throw InvalidOperationException with expected message", + ex => ex is InvalidOperationException { Message: "Name must be non-empty." }) + .AssertPassed(); + } + + [Scenario("Range validation using stateful Require overload (no captures)")] + [Fact] + public async Task Build_Throws_OnStatefulRangeValidation() + { + await Given("a builder with age 30 and a stateful range requirement [40, 120]", NewBuilder) + .When("configuring age and adding stateful validator", + b => b.With(SetAge30) + .Require((min: 40, max: 120, name: nameof(Person.Age)), + static (x, s) => + { + var v = x.Age; + return (v < s.min || v > s.max) + ? $"{s.name} must be within [{s.min}, {s.max}] but was {v}." + : null; + })) + .And("attempting to build", b => Record.Exception(() => b.Build())) + .Then("should throw with range message", + ex => ex is InvalidOperationException { Message: "Age must be within [40, 120] but was 30." }) + .AssertPassed(); + } + + [Scenario("RequireRange extension validates inclusive bounds")] + [Fact] + public async Task RequireRange_Extension_Works() + { + await Given("a builder with age -1 and RequireRange [0,130]", NewBuilder) + .When("configuring age and adding RequireRange", + b => b.With(SetAgeNeg1) + .RequireRange(static p => p.Age, 0, 130, nameof(Person.Age))) + .And("attempting to build", b => Record.Exception(() => b.Build())) + .Then("should throw with inclusive bounds message", + ex => ex is InvalidOperationException { Message: "Age must be within [0, 130] but was -1." }) + .AssertPassed(); + } + + [Scenario("Mutation order is preserved")] + [Fact] + public async Task Mutations_Are_Applied_In_Order() + { + await Given("a builder that records steps A then B", NewBuilder) + .When("adding two mutations", b => b.With(AppendStepA).With(AppendStepB)) + .And("building the person", BuildPerson) + .Then("steps should be applied in order", p => string.Join("", p.Steps) == "AB") + .AssertPassed(); + } + + [Scenario("Builder can be reused; subsequent builds reflect added mutations")] + [Fact] + public async Task Builder_Can_Be_Reused() + { + await Given("a builder with Name='Ada'", NewBuilder) + .When("adding initial mutation", b => b.With(SetNameAda)) + .And("building once", b => (builder: b, first: b.Build())) + .And("adding a second mutation (Age=30)", t => { t.builder.With(SetAge30); return t; }) + .And("building again", t => (t.first, second: t.builder.Build())) + .Then("first build has only first mutation applied", + t => t.first is { Name: "Ada", Age: 0 }) + .And("second build has both mutations applied", + t => t.second is { Name: "Ada", Age: 30 }) + .AssertPassed(); + } + + [Scenario("No mutations or validations returns factory instance")] + [Fact] + public async Task Build_Returns_Factory_Result_When_No_Config() + { + await Given("a fresh builder with default factory", NewBuilder) + .When("building immediately", BuildPerson) + .Then("result is a Person with default values", + p => p is { Name: null, Age: 0, Steps.Count: 0 }) + .AssertPassed(); + } +} diff --git a/test/PatternKit.Tests/PatternKit.Tests.csproj b/test/PatternKit.Tests/PatternKit.Tests.csproj index 6a43bc3..466805b 100644 --- a/test/PatternKit.Tests/PatternKit.Tests.csproj +++ b/test/PatternKit.Tests/PatternKit.Tests.csproj @@ -1,19 +1,25 @@  - net9.0 + net8.0;net9.0 enable enable false - - - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -21,8 +27,8 @@ - - + + diff --git a/test/PatternKit.Tests/Properties/AssemblyCoverage.cs b/test/PatternKit.Tests/Properties/AssemblyCoverage.cs new file mode 100644 index 0000000..598a1a7 --- /dev/null +++ b/test/PatternKit.Tests/Properties/AssemblyCoverage.cs @@ -0,0 +1,4 @@ +#if NETSTANDARD2_1 +// Exclude the entire assembly from coverage when built for netstandard2.1 +[assembly: System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +#endif \ No newline at end of file diff --git a/test/PatternKit.Tests/packages.lock.json b/test/PatternKit.Tests/packages.lock.json index 0758970..3981c18 100644 --- a/test/PatternKit.Tests/packages.lock.json +++ b/test/PatternKit.Tests/packages.lock.json @@ -1,30 +1,236 @@ { "version": 1, "dependencies": { + "net8.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.4, )", + "resolved": "6.0.4", + "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.14.1, )", + "resolved": "17.14.1", + "contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==", + "dependencies": { + "Microsoft.CodeCoverage": "17.14.1", + "Microsoft.TestPlatform.TestHost": "17.14.1" + } + }, + "TinyBDD.Xunit": { + "type": "Direct", + "requested": "[0.8.3, )", + "resolved": "0.8.3", + "contentHash": "BuDWkiCdzmgQt9feQNoz81YpWzrQp6YsMvVsTlvVfK9pAS+e7X8MKn+JGAaR+qwiBv5OCRqsTprUoKuNSo48jQ==", + "dependencies": { + "TinyBDD": "0.8.3", + "xunit.abstractions": "2.0.3", + "xunit.extensibility.core": "2.9.3" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.4, )", + "resolved": "3.1.4", + "contentHash": "5mj99LvCqrq3CNi06xYdyIAXOEh+5b33F2nErCzI5zWiDdLHXiPXEWFSUAF8zlIv0ZWqjZNCwHTQeAPYbF3pCg==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "p5RKAY9POvs3axwA/AQRuJeM8AHuE8h4qbP1NxQeGm0ep46aXz1oCLAp/oOYxX1GsjStgdhHrN3XXLLXr0+b3w==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "6SIp/6Bngk4jm2W36JekZbiIbFPdE/eMUtrJEqIqHGpd1zar3jvgnwxnpWQfzUiGrkyY8q8s6V82zkkEZozghA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.9" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "/hymojfWbE9AlDOa0mczR44m00Jj+T3+HZO0ZnVTI032fVycI0ZbNOVFP6kqZMcXiLSYXzR2ilcwaRi6dzeGyA==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "loxGGHE1FC2AefwPHzrjPq7X92LQm64qnU/whKfo6oWaceewPUVYQJBJs3S3E2qlWwnCpeZ+dGCPTX+5dgVAuQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "n4DCdnn2qs6V5U06Sx62FySEAZsJiJJgOzrPHDh9hPK7c2W8hEabC76F3Re3tGPjpiKa02RvB6FxZyxo8iICzg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.9", + "Microsoft.Extensions.Configuration.Binder": "9.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Options": "9.0.9", + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Options.DataAnnotations": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "Al+1FXnKKFygTXz0Zsa1+jYEPvsx5dKavlJxMXjRbrL6lmBhQZsVMhjuNB5lWvdRhdoxt5y/Q3v5kbLZLsXWdA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Options": "9.0.9" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "z4pyMePOrl733ltTowbN565PxBw1oAr8IHmIXNDiDqd22nFpYltX9KhrNC/qBWAG1/Zx5MHX+cOYhWJQYCO/iw==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ==", + "dependencies": { + "System.Reflection.Metadata": "8.0.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.14.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0" + } + }, + "TinyBDD": { + "type": "Transitive", + "resolved": "0.8.3", + "contentHash": "cUd2UGU5WoBmy/s4N5hI3lw4AVfuZXeuXrFJlP4RBdcF2YB5Tc3yL9jKsMfcLalXC2Tb0M+uublpyqE/cGfs8Q==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "patternkit.core": { + "type": "Project" + }, + "patternkit.examples": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "[9.0.9, )", + "Microsoft.Extensions.Configuration.Binder": "[9.0.9, )", + "Microsoft.Extensions.Options": "[9.0.9, )", + "Microsoft.Extensions.Options.ConfigurationExtensions": "[9.0.9, )", + "Microsoft.Extensions.Options.DataAnnotations": "[9.0.9, )", + "PatternKit.Core": "[1.0.0, )", + "PatternKit.Generators.Abstractions": "[1.0.0, )" + } + }, + "patternkit.generators.abstractions": { + "type": "Project" + } + }, "net9.0": { "coverlet.collector": { "type": "Direct", - "requested": "[6.0.2, )", - "resolved": "6.0.2", - "contentHash": "bJShQ6uWRTQ100ZeyiMqcFlhP7WJ+bCuabUs885dJiBEzMsJMSFr7BOyeCw4rgvQokteGi5rKQTlkhfQPUXg2A==" + "requested": "[6.0.4, )", + "resolved": "6.0.4", + "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" }, "Microsoft.NET.Test.Sdk": { "type": "Direct", - "requested": "[17.12.0, )", - "resolved": "17.12.0", - "contentHash": "kt/PKBZ91rFCWxVIJZSgVLk+YR+4KxTuHf799ho8WNiK5ZQpJNAEZCAWX86vcKrs+DiYjiibpYKdGZP6+/N17w==", + "requested": "[17.14.1, )", + "resolved": "17.14.1", + "contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==", "dependencies": { - "Microsoft.CodeCoverage": "17.12.0", - "Microsoft.TestPlatform.TestHost": "17.12.0" + "Microsoft.CodeCoverage": "17.14.1", + "Microsoft.TestPlatform.TestHost": "17.14.1" } }, "TinyBDD.Xunit": { "type": "Direct", - "requested": "[0.8.1, )", - "resolved": "0.8.1", - "contentHash": "wU9oJKzWlgNQvbYYlycpqvmnOjWlqgpIQJw9HrZ9XL5ZxCtUowuyD0l1/cnfeK0XaacRyPMHTpdHPNmybywZyw==", + "requested": "[0.8.3, )", + "resolved": "0.8.3", + "contentHash": "BuDWkiCdzmgQt9feQNoz81YpWzrQp6YsMvVsTlvVfK9pAS+e7X8MKn+JGAaR+qwiBv5OCRqsTprUoKuNSo48jQ==", "dependencies": { - "TinyBDD": "0.8.1", + "TinyBDD": "0.8.3", "xunit.abstractions": "2.0.3", "xunit.extensibility.core": "2.9.3" } @@ -51,46 +257,110 @@ }, "xunit.runner.visualstudio": { "type": "Direct", - "requested": "[2.8.2, )", - "resolved": "2.8.2", - "contentHash": "vm1tbfXhFmjFMUmS4M0J0ASXz3/U5XvXBa6DOQUL3fEz4Vt6YPhv+ESCarx6M6D+9kJkJYZKCNvJMas1+nVfmQ==" + "requested": "[3.1.4, )", + "resolved": "3.1.4", + "contentHash": "5mj99LvCqrq3CNi06xYdyIAXOEh+5b33F2nErCzI5zWiDdLHXiPXEWFSUAF8zlIv0ZWqjZNCwHTQeAPYbF3pCg==" }, "Microsoft.CodeCoverage": { "type": "Transitive", - "resolved": "17.12.0", - "contentHash": "4svMznBd5JM21JIG2xZKGNanAHNXplxf/kQDFfLHXQ3OnpJkayRK/TjacFjA+EYmoyuNXHo/sOETEfcYtAzIrA==" + "resolved": "17.14.1", + "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "p5RKAY9POvs3axwA/AQRuJeM8AHuE8h4qbP1NxQeGm0ep46aXz1oCLAp/oOYxX1GsjStgdhHrN3XXLLXr0+b3w==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "6SIp/6Bngk4jm2W36JekZbiIbFPdE/eMUtrJEqIqHGpd1zar3jvgnwxnpWQfzUiGrkyY8q8s6V82zkkEZozghA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.9" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "/hymojfWbE9AlDOa0mczR44m00Jj+T3+HZO0ZnVTI032fVycI0ZbNOVFP6kqZMcXiLSYXzR2ilcwaRi6dzeGyA==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "loxGGHE1FC2AefwPHzrjPq7X92LQm64qnU/whKfo6oWaceewPUVYQJBJs3S3E2qlWwnCpeZ+dGCPTX+5dgVAuQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "n4DCdnn2qs6V5U06Sx62FySEAZsJiJJgOzrPHDh9hPK7c2W8hEabC76F3Re3tGPjpiKa02RvB6FxZyxo8iICzg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.9", + "Microsoft.Extensions.Configuration.Binder": "9.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Options": "9.0.9", + "Microsoft.Extensions.Primitives": "9.0.9" + } + }, + "Microsoft.Extensions.Options.DataAnnotations": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "Al+1FXnKKFygTXz0Zsa1+jYEPvsx5dKavlJxMXjRbrL6lmBhQZsVMhjuNB5lWvdRhdoxt5y/Q3v5kbLZLsXWdA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Options": "9.0.9" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "z4pyMePOrl733ltTowbN565PxBw1oAr8IHmIXNDiDqd22nFpYltX9KhrNC/qBWAG1/Zx5MHX+cOYhWJQYCO/iw==" }, "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", - "resolved": "17.12.0", - "contentHash": "TDqkTKLfQuAaPcEb3pDDWnh7b3SyZF+/W9OZvWFp6eJCIiiYFdSB6taE2I6tWrFw5ywhzOb6sreoGJTI6m3rSQ==", + "resolved": "17.14.1", + "contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ==", "dependencies": { - "System.Reflection.Metadata": "1.6.0" + "System.Reflection.Metadata": "8.0.0" } }, "Microsoft.TestPlatform.TestHost": { "type": "Transitive", - "resolved": "17.12.0", - "contentHash": "MiPEJQNyADfwZ4pJNpQex+t9/jOClBGMiCiVVFuELCMSX2nmNfvUor3uFVxNNCg30uxDP8JDYfPnMXQzsfzYyg==", + "resolved": "17.14.1", + "contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==", "dependencies": { - "Microsoft.TestPlatform.ObjectModel": "17.12.0", - "Newtonsoft.Json": "13.0.1" + "Microsoft.TestPlatform.ObjectModel": "17.14.1", + "Newtonsoft.Json": "13.0.3" } }, "Newtonsoft.Json": { "type": "Transitive", - "resolved": "13.0.1", - "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" }, "System.Reflection.Metadata": { "type": "Transitive", - "resolved": "1.6.0", - "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0" + } }, "TinyBDD": { "type": "Transitive", - "resolved": "0.8.1", - "contentHash": "EBxLZ+fY3O2kQz3h3Up+8T9UGIjiRtm4sH/DPiHivnm2o2EjNXYrgf7SC4RwheeH8+kN6C7UWwjAZ/GrsMf+bw==" + "resolved": "0.8.3", + "contentHash": "cUd2UGU5WoBmy/s4N5hI3lw4AVfuZXeuXrFJlP4RBdcF2YB5Tc3yL9jKsMfcLalXC2Tb0M+uublpyqE/cGfs8Q==" }, "xunit.abstractions": { "type": "Transitive", @@ -130,8 +400,17 @@ "patternkit.examples": { "type": "Project", "dependencies": { - "PatternKit.Core": "[1.0.0, )" + "Microsoft.Extensions.Configuration.Abstractions": "[9.0.9, )", + "Microsoft.Extensions.Configuration.Binder": "[9.0.9, )", + "Microsoft.Extensions.Options": "[9.0.9, )", + "Microsoft.Extensions.Options.ConfigurationExtensions": "[9.0.9, )", + "Microsoft.Extensions.Options.DataAnnotations": "[9.0.9, )", + "PatternKit.Core": "[1.0.0, )", + "PatternKit.Generators.Abstractions": "[1.0.0, )" } + }, + "patternkit.generators.abstractions": { + "type": "Project" } } }