From 0dc57a0f158e127b3e23ec142873b56a266a0bcc Mon Sep 17 00:00:00 2001 From: "Jerrett D. Davis" Date: Mon, 13 Oct 2025 21:56:54 -0500 Subject: [PATCH 1/4] feat(behavioral): add Template Method pattern - Add abstract TemplateMethod with optional synchronization and before/after hooks - Add fluent Template with Before/After/OnError/Synchronized and Execute/TryExecute - Add examples: subclassing demo (DataProcessor) and fluent demo (TemplateFluentDemo) - Add tests: TemplateMethodTests and TemplateFluentTests for correctness, concurrency, and error handling - Add docs for pattern and demo; update Behavioral patterns ToC and Examples index Build: PASS (multi-target) Tests: PASS (840/840) --- docs/examples/index.md | 3 + docs/examples/template-method-demo.md | 66 +++++++++ .../behavioral/template/templatemethod.md | 0 docs/patterns/toc.yml | 45 +----- .../Behavioral/Template/Template.cs | 139 ++++++++++++++++++ .../Behavioral/Template/TemplateMethod.cs | 59 ++++++++ .../TemplateDemo/TemplateDemo.cs | 37 +++++ .../TemplateDemo/TemplateFluentDemo.cs | 0 .../Behavioral/TemplateFluentTests.cs | 96 ++++++++++++ .../Behavioral/TemplateMethodTests.cs | 58 ++++++++ 10 files changed, 460 insertions(+), 43 deletions(-) create mode 100644 docs/examples/template-method-demo.md create mode 100644 docs/patterns/behavioral/template/templatemethod.md create mode 100644 src/PatternKit.Core/Behavioral/Template/Template.cs create mode 100644 src/PatternKit.Core/Behavioral/Template/TemplateMethod.cs create mode 100644 src/PatternKit.Examples/TemplateDemo/TemplateDemo.cs create mode 100644 src/PatternKit.Examples/TemplateDemo/TemplateFluentDemo.cs create mode 100644 test/PatternKit.Tests/Behavioral/TemplateFluentTests.cs create mode 100644 test/PatternKit.Tests/Behavioral/TemplateMethodTests.cs diff --git a/docs/examples/index.md b/docs/examples/index.md index ca8f29a..141e028 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -46,6 +46,9 @@ Welcome! This section collects small, focused demos that show **how to compose b * **State Machine — Order Lifecycle** A fluent state machine driving an order lifecycle with entry/exit hooks, transition effects, and default per‑state behavior. Shows determinism (first‑match wins), internal (Stay) vs cross‑state transitions, and log/audit side‑effects. +* **Template Method Data Processor** + Shows how to use the Template Method pattern to define a reusable, extensible workflow for data processing. Demonstrates hooks for pre/post processing, thread-safety, and easy extensibility. See [Template Method Demo](template-method-demo.md). + ## How to run From the repo root: diff --git a/docs/examples/template-method-demo.md b/docs/examples/template-method-demo.md new file mode 100644 index 0000000..f1f30f9 --- /dev/null +++ b/docs/examples/template-method-demo.md @@ -0,0 +1,66 @@ +# Template Method Demo + +This demo shows two ways to use PatternKit’s Template Method: + +- Subclassing: derive from `TemplateMethod` and override hooks. +- Fluent: compose a `Template` with `Before/After/OnError/Synchronized`. + +Both give you a consistent workflow shape with customizable steps and optional synchronization. + +## Subclassing demo (DataProcessor) +Counts words in a string while logging before/after. + +```csharp +public class DataProcessor : TemplateMethod +{ + protected override void OnBefore(string context) + { + Console.WriteLine($"Preparing to process: {context}"); + } + + protected override int Step(string context) + { + return context.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; + } + + protected override void OnAfter(string context, int result) + { + Console.WriteLine($"Processed '{context}' with result: {result}"); + } +} + +var processor = new DataProcessor(); +var result = processor.Execute("The quick brown fox jumps over the lazy dog"); +Console.WriteLine($"Word count: {result}"); +``` + +## Fluent demo (TemplateFluentDemo) +Same behavior using the fluent builder, plus non-throwing `TryExecute` and an `OnError` hook. + +```csharp +var tpl = Template + .Create(ctx => ctx.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length) + .Before(ctx => Console.WriteLine($"[Before] Input: '{ctx}'")) + .After((ctx, res) => Console.WriteLine($"[After] '{ctx}' -> words: {res}")) + .OnError((ctx, err) => Console.WriteLine($"[Error] Input '{ctx}', error: {err}")) + .Synchronized() // optional + .Build(); + +if (tpl.TryExecute("The quick brown fox", out var count, out var error)) + Console.WriteLine($"Word count: {count}"); +else + Console.WriteLine($"Failed: {error}"); +``` + +## When to use which +- Prefer subclassing when the algorithm is a stable concept in your domain, and you want a strongly‑named type. +- Prefer fluent when you want to compose quickly, add multiple hooks, or opt into `TryExecute` easily. + +## Thread safety +- Subclassing: override `Synchronized` to serialize `Execute` calls. +- Fluent: call `.Synchronized()` on the builder. +- Leave off for maximum concurrency when your step/hook logic is already thread-safe. + +## See Also +- [Template Method Pattern Documentation](../patterns/behavioral/template/templatemethod.md) +- Refactoring Guru: Template Method — https://refactoring.guru/design-patterns/template-method diff --git a/docs/patterns/behavioral/template/templatemethod.md b/docs/patterns/behavioral/template/templatemethod.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 0532cd6..a4b8fe0 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -1,46 +1,5 @@ -- name: Patterns - href: index.md - items: - - name: Behavioral - items: - - name: Chain of Responsibility - items: - - name: ActionChain - href: behavioral/chain/actionchain.md - - name: ResultChain - href: behavioral/chain/resultchain.md - - name: Strategy - items: - - name: Strategy - href: behavioral/strategy/strategy.md - - name: TryStrategy - href: behavioral/strategy/trystrategy.md - - name: ActionStrategy - href: behavioral/strategy/actionstrategy.md - - name: AsyncStrategy - href: behavioral/strategy/asyncstrategy.md - - name: Iterator - items: - - name: ReplayableSequence - href: behavioral/iterator/replayablesequence.md - - name: WindowSequence - href: behavioral/iterator/windowsequence.md - - name: Flow - href: behavioral/iterator/flow.md - - name: AsyncFlow - href: behavioral/iterator/asyncflow.md - - name: Mediator - href: behavioral/mediator/mediator.md - - name: Command - href: behavioral/command/command.md - - name: Observer - items: - - name: Observer - href: behavioral/observer/observer.md - - name: AsyncObserver - href: behavioral/observer/asyncobserver.md - - name: State - href: behavioral/state/state.md + - name: TemplateMethod + href: behavioral/template/templatemethod.md - name: Creational items: - name: Builder diff --git a/src/PatternKit.Core/Behavioral/Template/Template.cs b/src/PatternKit.Core/Behavioral/Template/Template.cs new file mode 100644 index 0000000..93f31b6 --- /dev/null +++ b/src/PatternKit.Core/Behavioral/Template/Template.cs @@ -0,0 +1,139 @@ +using System; + +namespace PatternKit.Behavioral.Template; + +/// +/// Fluent, allocation-light Template that defines an algorithm skeleton with optional hooks. +/// Build once, then call Execute or TryExecute. +/// +/// Input context type. +/// Result type. +public sealed class Template +{ + public delegate void BeforeHook(TContext context); + public delegate TResult StepHook(TContext context); + public delegate void AfterHook(TContext context, TResult result); + public delegate void ErrorHook(TContext context, string error); + + private readonly BeforeHook? _before; + private readonly StepHook _step; + private readonly AfterHook? _after; + private readonly ErrorHook? _onError; + private readonly bool _synchronized; + private readonly object _sync = new(); + + private Template(BeforeHook? before, StepHook step, AfterHook? after, ErrorHook? onError, bool synchronized) + { + _before = before; + _step = step; + _after = after; + _onError = onError; + _synchronized = synchronized; + } + + /// + /// Execute the template: before → step → after. Throws on errors. + /// + public TResult Execute(TContext context) + { + if (_synchronized) + { + lock (_sync) + { + _before?.Invoke(context); + var result = _step(context); + _after?.Invoke(context, result); + return result; + } + } + + _before?.Invoke(context); + var res = _step(context); + _after?.Invoke(context, res); + return res; + } + + /// + /// Like , but returns false with an error message instead of throwing. + /// + public bool TryExecute(TContext context, out TResult result, out string? error) + { + try + { + if (_synchronized) + { + lock (_sync) + { + _before?.Invoke(context); + result = _step(context); + _after?.Invoke(context, result); + error = null; + return true; + } + } + + _before?.Invoke(context); + result = _step(context); + _after?.Invoke(context, result); + error = null; + return true; + } + catch (Exception ex) + { + _onError?.Invoke(context, ex.Message); + result = default!; + error = ex.Message; + return false; + } + } + + /// Create a builder for this template. + public static Builder Create(StepHook step) => new(step); + + /// Fluent builder for . + public sealed class Builder + { + private BeforeHook? _before; + private readonly StepHook _step; + private AfterHook? _after; + private ErrorHook? _onError; + private bool _synchronized; + + internal Builder(StepHook step) + { + _step = step ?? throw new ArgumentNullException(nameof(step)); + } + + /// Add a pre-step hook. + public Builder Before(BeforeHook before) + { + _before = (_before is null) ? before : (BeforeHook)Delegate.Combine(_before, before); + return this; + } + + /// Add a post-step hook. + public Builder After(AfterHook after) + { + _after = (_after is null) ? after : (AfterHook)Delegate.Combine(_after, after); + return this; + } + + /// Add an error hook invoked when would throw. + public Builder OnError(ErrorHook onError) + { + _onError = (_onError is null) ? onError : (ErrorHook)Delegate.Combine(_onError, onError); + return this; + } + + /// Synchronize executions across threads. + public Builder Synchronized(bool synchronized = true) + { + _synchronized = synchronized; + return this; + } + + /// Build an immutable template. + public Template Build() + => new(_before, _step, _after, _onError, _synchronized); + } +} diff --git a/src/PatternKit.Core/Behavioral/Template/TemplateMethod.cs b/src/PatternKit.Core/Behavioral/Template/TemplateMethod.cs new file mode 100644 index 0000000..e2b75f8 --- /dev/null +++ b/src/PatternKit.Core/Behavioral/Template/TemplateMethod.cs @@ -0,0 +1,59 @@ +using System; + +namespace PatternKit.Behavioral.Template +{ + /// + /// Generic Template Method base class. + /// Defines the skeleton of an algorithm, allowing subclasses to override steps without changing the structure. + /// + /// The type of the context passed to the algorithm. + /// The type of the result produced by the algorithm. + public abstract class TemplateMethod + { + private readonly object _sync = new object(); + + /// + /// Set to true to synchronize calls across threads. + /// Default is false to allow concurrent executions when subclass is stateless or thread-safe. + /// + protected virtual bool Synchronized => false; + + /// + /// Executes the algorithm using the provided context. + /// Calls , then , then . + /// + public TResult Execute(TContext context) + { + if (Synchronized) + { + lock (_sync) + { + OnBefore(context); + var result = Step(context); + OnAfter(context, result); + return result; + } + } + + OnBefore(context); + var res = Step(context); + OnAfter(context, res); + return res; + } + + /// + /// Optional hook before the main step. + /// + protected virtual void OnBefore(TContext context) { } + + /// + /// The main step of the algorithm. Must be implemented by subclasses. + /// + protected abstract TResult Step(TContext context); + + /// + /// Optional hook after the main step. + /// + protected virtual void OnAfter(TContext context, TResult result) { } + } +} diff --git a/src/PatternKit.Examples/TemplateDemo/TemplateDemo.cs b/src/PatternKit.Examples/TemplateDemo/TemplateDemo.cs new file mode 100644 index 0000000..ec734c2 --- /dev/null +++ b/src/PatternKit.Examples/TemplateDemo/TemplateDemo.cs @@ -0,0 +1,37 @@ +using System; +using PatternKit.Behavioral.Template; + +namespace PatternKit.Examples.TemplateDemo +{ + // Example: DataProcessor using TemplateMethod + public class DataProcessor : TemplateMethod + { + protected override void OnBefore(string context) + { + Console.WriteLine($"Preparing to process: {context}"); + } + + protected override int Step(string context) + { + // Simulate processing: count words + return context.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; + } + + protected override void OnAfter(string context, int result) + { + Console.WriteLine($"Processed '{context}' with result: {result}"); + } + } + + // Demo runner + public static class TemplateMethodDemo + { + public static void Run() + { + var processor = new DataProcessor(); + int result = processor.Execute("The quick brown fox jumps over the lazy dog"); + Console.WriteLine($"Word count: {result}"); + } + } +} + diff --git a/src/PatternKit.Examples/TemplateDemo/TemplateFluentDemo.cs b/src/PatternKit.Examples/TemplateDemo/TemplateFluentDemo.cs new file mode 100644 index 0000000..e69de29 diff --git a/test/PatternKit.Tests/Behavioral/TemplateFluentTests.cs b/test/PatternKit.Tests/Behavioral/TemplateFluentTests.cs new file mode 100644 index 0000000..33d9495 --- /dev/null +++ b/test/PatternKit.Tests/Behavioral/TemplateFluentTests.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using PatternKit.Behavioral.Template; +using Xunit; + +namespace PatternKit.Tests.Behavioral +{ + public class TemplateFluentTests + { + [Fact] + public void Execute_RunsHooksAndStep_InOrder() + { + var calls = new ConcurrentQueue(); + var tpl = Template + .Create(ctx => { calls.Enqueue($"step:{ctx}"); return ctx.Length; }) + .Before(ctx => calls.Enqueue($"before:{ctx}")) + .After((ctx, res) => calls.Enqueue($"after:{ctx}:{res}")) + .Build(); + + var result = tpl.Execute("abc"); + + Assert.Equal(3, result); + Assert.Collection(calls, + s => Assert.Equal("before:abc", s), + s => Assert.Equal("step:abc", s), + s => Assert.Equal("after:abc:3", s)); + } + + [Fact] + public void TryExecute_CatchesErrors_AndInvokesOnError() + { + string? observed = null; + var tpl = Template + .Create(_ => throw new InvalidOperationException("boom")) + .OnError((ctx, err) => observed = $"ctx={ctx};err={err}") + .Build(); + + var ok = tpl.TryExecute(42, out var result, out var error); + + Assert.False(ok); + Assert.Equal(default, result); + Assert.NotNull(error); + Assert.Equal("ctx=42;err=boom", observed); + } + + [Fact] + public async Task Hooks_Compose_Multicast() + { + int beforeCount = 0; + int afterCount = 0; + var tpl = Template + .Create(ctx => ctx.ToUpperInvariant()) + .Before(_ => Interlocked.Increment(ref beforeCount)) + .Before(_ => Interlocked.Increment(ref beforeCount)) + .After((_, _) => Interlocked.Increment(ref afterCount)) + .After((_, _) => Interlocked.Increment(ref afterCount)) + .Build(); + + var t1 = Task.Run(() => tpl.Execute("x")); + var t2 = Task.Run(() => tpl.Execute("y")); + await Task.WhenAll(t1, t2); + + Assert.Equal(4, beforeCount); + Assert.Equal(4, afterCount); + } + + [Fact] + public async Task Synchronized_EnforcesMutualExclusion() + { + int concurrent = 0; + int maxConcurrent = 0; + + var tpl = Template + .Create(ctx => + { + var c = Interlocked.Increment(ref concurrent); + maxConcurrent = Math.Max(maxConcurrent, c); + Thread.Sleep(20); + Interlocked.Decrement(ref concurrent); + return ctx * 2; + }) + .Synchronized() + .Build(); + + var tasks = new Task[8]; + for (int i = 0; i < tasks.Length; i++) tasks[i] = Task.Run(() => tpl.Execute(2)); + var results = await Task.WhenAll(tasks); + + Assert.All(results, r => Assert.Equal(4, r)); + Assert.Equal(1, maxConcurrent); + } + } +} + diff --git a/test/PatternKit.Tests/Behavioral/TemplateMethodTests.cs b/test/PatternKit.Tests/Behavioral/TemplateMethodTests.cs new file mode 100644 index 0000000..bb26368 --- /dev/null +++ b/test/PatternKit.Tests/Behavioral/TemplateMethodTests.cs @@ -0,0 +1,58 @@ +using System; +using System.Threading.Tasks; +using PatternKit.Behavioral.Template; +using Xunit; + +namespace PatternKit.Tests.Behavioral +{ + public class TemplateMethodTests + { + private class TestTemplate : TemplateMethod + { + public bool BeforeCalled { get; private set; } + public bool AfterCalled { get; private set; } + + protected override void OnBefore(string context) + { + BeforeCalled = true; + } + + protected override string Step(string context) + { + return context.ToUpperInvariant(); + } + + protected override void OnAfter(string context, string result) + { + AfterCalled = true; + } + } + + [Fact] + public void ExecutesAlgorithmAndHooks() + { + var template = new TestTemplate(); + var result = template.Execute("test"); + Assert.Equal("TEST", result); + Assert.True(template.BeforeCalled); + Assert.True(template.AfterCalled); + } + + [Fact] + public void IsThreadSafe() + { + var template = new TestTemplate(); + var tasks = new Task[10]; + for (int i = 0; i < 10; i++) + { + tasks[i] = Task.Run(() => template.Execute($"thread-{i}")); + } + Task.WaitAll(tasks); + foreach (var task in tasks) + { + Assert.StartsWith("THREAD-", task.Result); + } + } + } +} + From 2dd6ef9ea1d09c0cec7f7fa531425c00c33291fa Mon Sep 17 00:00:00 2001 From: "Jerrett D. Davis" Date: Tue, 14 Oct 2025 00:11:41 -0500 Subject: [PATCH 2/4] feat(template):implemented fluent template pattern --- docs/examples/index.md | 3 + docs/examples/template-method-async-demo.md | 68 ++ .../behavioral/template/templatemethod.md | 158 ++++ .../Behavioral/State/StateMachine.cs | 1 - .../Behavioral/Template/AsyncTemplate.cs | 180 +++++ .../Template/AsyncTemplateMethod.cs | 61 ++ .../Behavioral/Template/Template.cs | 2 - .../Behavioral/Template/TemplateMethod.cs | 91 ++- .../ProxyDemo/ProxyDemo.cs | 5 +- .../TemplateDemo/TemplateAsyncDemo.cs | 75 ++ .../TemplateDemo/TemplateDemo.cs | 49 +- .../TemplateDemo/TemplateFluentDemo.cs | 21 + src/PatternKit.Generators/packages.lock.json | 188 ----- .../AsyncStateDemo/AsyncStateDemoTests.cs | 4 - .../ProxyDemo/ConsoleOutputCollection.cs | 2 - .../ProxyDemo/ProxyDemoParameterlessTests.cs | 4 - .../StateDemo/StateDemoTests.cs | 2 - .../ComposedStrategiesTests.Extended.cs | 421 +++++----- .../Composed/ComposedStrategiesTests.cs | 724 +++++++++--------- .../Behavioral/AsyncTemplateFluentTests.cs | 157 ++++ .../Behavioral/AsyncTemplateMethodTests.cs | 99 +++ .../Behavioral/Iterator/AsyncFlowTests.cs | 1 - .../State/AsyncStateMachineTests.cs | 4 - .../Behavioral/State/StateMachineTests.cs | 4 - .../Behavioral/TemplateFluentTests.cs | 188 ++--- .../Behavioral/TemplateMethodTests.cs | 87 +-- 26 files changed, 1598 insertions(+), 1001 deletions(-) create mode 100644 docs/examples/template-method-async-demo.md create mode 100644 src/PatternKit.Core/Behavioral/Template/AsyncTemplate.cs create mode 100644 src/PatternKit.Core/Behavioral/Template/AsyncTemplateMethod.cs create mode 100644 src/PatternKit.Examples/TemplateDemo/TemplateAsyncDemo.cs create mode 100644 test/PatternKit.Tests/Behavioral/AsyncTemplateFluentTests.cs create mode 100644 test/PatternKit.Tests/Behavioral/AsyncTemplateMethodTests.cs diff --git a/docs/examples/index.md b/docs/examples/index.md index 141e028..d219ae4 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -49,6 +49,9 @@ Welcome! This section collects small, focused demos that show **how to compose b * **Template Method Data Processor** Shows how to use the Template Method pattern to define a reusable, extensible workflow for data processing. Demonstrates hooks for pre/post processing, thread-safety, and easy extensibility. See [Template Method Demo](template-method-demo.md). +* **Template Method Async Pipeline** + End-to-end async pipeline (fetch → transform → store) with cancellation, optional synchronization, and error observation. Shows both subclassing (`AsyncTemplateMethod`) and fluent (`AsyncTemplate`) approaches. See [Template Method Async Demo](template-method-async-demo.md). + ## How to run From the repo root: diff --git a/docs/examples/template-method-async-demo.md b/docs/examples/template-method-async-demo.md new file mode 100644 index 0000000..06a29a6 --- /dev/null +++ b/docs/examples/template-method-async-demo.md @@ -0,0 +1,68 @@ +# Template Method Async Demo + +This demo shows non-trivial, end-to-end async workflows with PatternKit’s async Template variants, including cancellation, concurrency control, and error observation. + +## Async subclassing demo: AsyncDataPipeline +A 3-stage pipeline (fetch → transform → store) with optional serialization and cancellation. + +```csharp +public sealed class AsyncDataPipeline : AsyncTemplateMethod +{ + protected override bool Synchronized => false; // enable only when shared mutable state requires it + + protected override async ValueTask OnBeforeAsync(int id, CancellationToken ct) + { + Console.WriteLine($"[BeforeAsync] {id}"); + await Task.Yield(); + } + + protected override async ValueTask StepAsync(int id, CancellationToken ct) + { + await Task.Delay(25, ct); // fetch + await Task.Delay(10, ct); // transform + await Task.Delay(5, ct); // store + return $"VAL-{id}"; + } + + protected override ValueTask OnAfterAsync(int id, string result, CancellationToken ct) + { + Console.WriteLine($"[AfterAsync] {id} -> {result}"); + return default; + } +} + +var pipe = new AsyncDataPipeline(); +using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); +var outVal = await pipe.ExecuteAsync(42, cts.Token); +``` + +## Async fluent demo: TemplateAsyncFluentDemo +Same shape using the fluent builder with multiple hooks and error handling. + +```csharp +var tpl = AsyncTemplate + .Create(async (id, ct) => + { + await Task.Delay(15, ct); + if (id < 0) throw new InvalidOperationException("invalid id"); + return $"VAL-{id}"; + }) + .Before((id, ct) => { Console.WriteLine($"[BeforeAsync] {id}"); return default; }) + .After((id, res, ct) => { Console.WriteLine($"[AfterAsync] {id} -> {res}"); return default; }) + .OnError((id, err, ct) => { Console.WriteLine($"[ErrorAsync] {id}: {err}"); return default; }) + .Synchronized(false) + .Build(); + +var (ok, res, err) = await tpl.TryExecuteAsync(42); +Console.WriteLine(ok ? $"OK: {res}" : $"ERR: {err}"); +``` + +## Guidance +- Prefer async for I/O-bound steps or where cancellation must be respected. +- Use `.Synchronized()` sparingly; it introduces a critical section. Keep steps idempotent and fast. +- Use `TryExecuteAsync` to keep control flow non-throwing and centralize error observation. + +## See Also +- [Template Method Pattern](../patterns/behavioral/template/templatemethod.md) +- Synchronous demo: [Template Method Demo](template-method-demo.md) + diff --git a/docs/patterns/behavioral/template/templatemethod.md b/docs/patterns/behavioral/template/templatemethod.md index e69de29..af67cab 100644 --- a/docs/patterns/behavioral/template/templatemethod.md +++ b/docs/patterns/behavioral/template/templatemethod.md @@ -0,0 +1,158 @@ +# Template Method Pattern + +**Category:** Behavioral + +## Overview +Template Method defines the skeleton of an algorithm in a base class, allowing specific steps to be customized without changing the overall structure. PatternKit offers two complementary shapes: + +- Subclassing API: derive from `TemplateMethod` and override hooks. +- Fluent API: compose a `Template` with `Before/After/OnError/Synchronized` and `Execute/TryExecute`. + +Common traits: +- Generic and type-safe (any context/result types) +- Allocation-light, production-shaped APIs +- Optional synchronization for thread safety +- Clear separation of “when/where” (hooks) and “what” (the main step) + +## Structure +- `TemplateMethod` (abstract) + - `Execute(context)` — calls `OnBefore`, `Step`, `OnAfter` in order + - `protected virtual void OnBefore(context)` — optional pre-step hook + - `protected abstract TResult Step(context)` — required main step + - `protected virtual void OnAfter(context, result)` — optional post-step hook + - `protected virtual bool Synchronized` — set to `true` to serialize executions + +- `Template` (fluent) + - `Execute(context)` — runs before → step → after; throws on error + - `TryExecute(context, out result, out error)` — non-throwing path + - `Create(step)` → `.Before(...)` → `.After(...)` → `.OnError(...)` → `.Synchronized()` → `.Build()` + +## Subclassing Example +```csharp +public sealed class DataProcessor : TemplateMethod +{ + protected override void OnBefore(string context) + => Console.WriteLine($"Preparing to process: {context}"); + + protected override int Step(string context) + => context.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; + + protected override void OnAfter(string context, int result) + => Console.WriteLine($"Processed '{context}' with result: {result}"); + + // Optional: serialize concurrent Execute calls + protected override bool Synchronized => true; +} + +var processor = new DataProcessor(); +var count = processor.Execute("The quick brown fox"); +``` + +## Fluent Example +```csharp +var template = Template + .Create(ctx => ctx.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length) + .Before(ctx => Console.WriteLine($"[Before] '{ctx}'")) + .After((ctx, res) => Console.WriteLine($"[After] '{ctx}' -> {res}")) + .OnError((ctx, err) => Console.WriteLine($"[Error] '{ctx}': {err}")) + .Synchronized() // optional + .Build(); + +if (template.TryExecute("The quick brown fox", out var result, out var error)) + Console.WriteLine($"Words: {result}"); +else + Console.WriteLine($"Failed: {error}"); +``` + +## Async variants +PatternKit also provides first-class async variants with cancellation and optional synchronization: + +- `AsyncTemplateMethod` (abstract) + - `ExecuteAsync(context, cancellationToken)` — calls `OnBeforeAsync`, `StepAsync`, `OnAfterAsync` in order + - `protected virtual ValueTask OnBeforeAsync(context, ct)` — optional pre-step hook + - `protected abstract ValueTask StepAsync(context, ct)` — required main step + - `protected virtual ValueTask OnAfterAsync(context, result, ct)` — optional post-step hook + - `protected virtual bool Synchronized` — set to `true` to serialize `ExecuteAsync` calls (uses `SemaphoreSlim`) + +- `AsyncTemplate` (fluent) + - `ExecuteAsync(context, ct)` — runs before → step → after; throws on error + - `TryExecuteAsync(context, ct)` — returns `(ok, result, error)` without throwing + - `Create(async (ctx, ct) => ...)` → `.Before(...)`/`.After(...)`/`.OnError(...)` (async or sync overloads) → `.Synchronized()` → `.Build()` + +### Async subclassing example +```csharp +public sealed class AsyncDataPipeline : AsyncTemplateMethod +{ + protected override bool Synchronized => false; // enable for strict serialization + + protected override async ValueTask OnBeforeAsync(int id, CancellationToken ct) + { + Console.WriteLine($"[BeforeAsync] {id}"); + await Task.Yield(); + } + + protected override async ValueTask StepAsync(int id, CancellationToken ct) + { + await Task.Delay(25, ct); // fetch + await Task.Delay(10, ct); // transform + await Task.Delay(5, ct); // store + return $"VAL-{id}"; + } + + protected override ValueTask OnAfterAsync(int id, string result, CancellationToken ct) + { + Console.WriteLine($"[AfterAsync] {id} -> {result}"); + return default; // completed + } +} + +var pipe = new AsyncDataPipeline(); +using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); +var outVal = await pipe.ExecuteAsync(42, cts.Token); +``` + +### Async fluent example +```csharp +var tpl = AsyncTemplate + .Create(async (id, ct) => + { + await Task.Delay(15, ct); + if (id < 0) throw new InvalidOperationException("invalid id"); + return $"VAL-{id}"; + }) + .Before((id, ct) => { Console.WriteLine($"[BeforeAsync] {id}"); return default; }) + .After((id, res, ct) => { Console.WriteLine($"[AfterAsync] {id} -> {res}"); return default; }) + .OnError((id, err, ct) => { Console.WriteLine($"[ErrorAsync] {id}: {err}"); return default; }) + .Synchronized() // optional + .Build(); + +var (ok, result, error) = await tpl.TryExecuteAsync(42); +``` + +### Guidance +- Prefer async variants for I/O-bound steps or when cancellation needs to flow end-to-end. +- Use `.Synchronized()` or override `Synchronized` only when shared mutable state demands serialization. +- Choose `TryExecuteAsync` when you need non-throwing control flow and centralized error observation. + +## When to Use +- You need a consistent workflow with customizable steps. +- You want to prevent structural drift while enabling tailored behaviors. +- You need optional error handling and synchronization without external plumbing. + +## Thread Safety +- Subclassing: override `Synchronized` to serialize `Execute` calls via a per-instance lock. +- Fluent: call `.Synchronized()` on the builder to enable a per-instance lock. +- For stateless or externally synchronized code, leave synchronization off for maximal concurrency. + +## Error Handling +- Subclassing: let exceptions bubble; catch externally if needed. +- Fluent: use `TryExecute` to avoid throwing, and `.OnError(...)` to observe errors. + +## Related Patterns +- Strategy: swap entire algorithms rather than customizing steps inline. +- Chain of Responsibility: linear rule packs with stop/continue semantics. +- State: behavior that changes with state; Template Method keeps structure fixed. + +## See Also +- Refactoring Guru: Template Method — https://refactoring.guru/design-patterns/template-method +- Examples: see the Template Method demos in the Examples section. diff --git a/src/PatternKit.Core/Behavioral/State/StateMachine.cs b/src/PatternKit.Core/Behavioral/State/StateMachine.cs index 4ee690a..c4180cf 100644 --- a/src/PatternKit.Core/Behavioral/State/StateMachine.cs +++ b/src/PatternKit.Core/Behavioral/State/StateMachine.cs @@ -1,5 +1,4 @@ using System.Collections.ObjectModel; -using PatternKit.Common; namespace PatternKit.Behavioral.State; diff --git a/src/PatternKit.Core/Behavioral/Template/AsyncTemplate.cs b/src/PatternKit.Core/Behavioral/Template/AsyncTemplate.cs new file mode 100644 index 0000000..cb9fcc0 --- /dev/null +++ b/src/PatternKit.Core/Behavioral/Template/AsyncTemplate.cs @@ -0,0 +1,180 @@ +namespace PatternKit.Behavioral.Template; + +/// +/// Fluent, allocation-light async Template that defines an algorithm skeleton with optional async hooks. +/// Build once, then call ExecuteAsync or TryExecuteAsync. +/// +/// Input context type. +/// Result type. +public sealed class AsyncTemplate +{ + public delegate ValueTask BeforeHookAsync(TContext context, CancellationToken cancellationToken); + + public delegate ValueTask StepHookAsync(TContext context, CancellationToken cancellationToken); + + public delegate ValueTask AfterHookAsync(TContext context, TResult result, CancellationToken cancellationToken); + + public delegate ValueTask ErrorHookAsync(TContext context, string error, CancellationToken cancellationToken); + + private readonly BeforeHookAsync[] _before; + private readonly StepHookAsync _step; + private readonly AfterHookAsync[] _after; + private readonly ErrorHookAsync[] _onError; + private readonly bool _synchronized; + private readonly SemaphoreSlim _mutex = new(1, 1); + + private AsyncTemplate( + List before, + StepHookAsync step, + List after, + List onError, + bool synchronized) + { + _before = before.ToArray(); + _step = step; + _after = after.ToArray(); + _onError = onError.ToArray(); + _synchronized = synchronized; + } + + /// Execute the template: before → step → after. Throws on errors. + public async Task ExecuteAsync(TContext context, CancellationToken cancellationToken = default) + { + if (_synchronized) + { + await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await ExecuteCoreAsync(context, cancellationToken).ConfigureAwait(false); + } + finally + { + _mutex.Release(); + } + } + + return await ExecuteCoreAsync(context, cancellationToken).ConfigureAwait(false); + } + + /// Like ExecuteAsync, but returns false with an error message instead of throwing. + public async Task<(bool ok, TResult? result, string? error)> TryExecuteAsync(TContext context, CancellationToken cancellationToken = default) + { + try + { + var res = await ExecuteAsync(context, cancellationToken).ConfigureAwait(false); + return (true, res, null); + } + catch (Exception ex) + { + foreach (var h in _onError) + { + try + { + await h(context, ex.Message, cancellationToken).ConfigureAwait(false); + } + catch + { + /* swallow error hooks */ + } + } + + return (false, default, ex.Message); + } + } + + private async Task ExecuteCoreAsync(TContext context, CancellationToken cancellationToken) + { + foreach (var h in _before) + await h(context, cancellationToken).ConfigureAwait(false); + + var result = await _step(context, cancellationToken).ConfigureAwait(false); + + foreach (var h in _after) + await h(context, result, cancellationToken).ConfigureAwait(false); + + return result; + } + + /// Create a builder for this async template. + public static Builder Create(StepHookAsync step) => new(step); + + /// Fluent builder for . + public sealed class Builder + { + private readonly List _before = new(2); + private readonly StepHookAsync _step; + private readonly List _after = new(2); + private readonly List _onError = new(1); + private bool _synchronized; + + internal Builder(StepHookAsync step) + { + _step = step ?? throw new ArgumentNullException(nameof(step)); + } + + /// Add an async pre-step hook. + public Builder Before(BeforeHookAsync before) + { + _before.Add(before); + return this; + } + + /// Add a sync pre-step hook. + public Builder Before(Action before) + { + _before.Add((ctx, _) => + { + before(ctx); + return default; + }); + return this; + } + + /// Add an async post-step hook. + public Builder After(AfterHookAsync after) + { + _after.Add(after); + return this; + } + + /// Add a sync post-step hook. + public Builder After(Action after) + { + _after.Add((ctx, res, _) => + { + after(ctx, res); + return default; + }); + return this; + } + + /// Add an async error hook invoked when ExecuteAsync would throw. + public Builder OnError(ErrorHookAsync onError) + { + _onError.Add(onError); + return this; + } + + /// Add a sync error hook invoked when ExecuteAsync would throw. + public Builder OnError(Action onError) + { + _onError.Add((ctx, err, _) => + { + onError(ctx, err); + return default; + }); + return this; + } + + /// Synchronize executions across threads. + public Builder Synchronized(bool synchronized = true) + { + _synchronized = synchronized; + return this; + } + + /// Build an immutable async template. + public AsyncTemplate Build() + => new(_before, _step, _after, _onError, _synchronized); + } +} \ No newline at end of file diff --git a/src/PatternKit.Core/Behavioral/Template/AsyncTemplateMethod.cs b/src/PatternKit.Core/Behavioral/Template/AsyncTemplateMethod.cs new file mode 100644 index 0000000..b4b52e3 --- /dev/null +++ b/src/PatternKit.Core/Behavioral/Template/AsyncTemplateMethod.cs @@ -0,0 +1,61 @@ +namespace PatternKit.Behavioral.Template; + +/// +/// Generic async Template Method base class. +/// Defines the skeleton of an algorithm, allowing subclasses to override async steps without changing the structure. +/// +/// The type of the context passed to the algorithm. +/// The type of the result produced by the algorithm. +public abstract class AsyncTemplateMethod +{ + private readonly SemaphoreSlim _mutex = new(1, 1); + + /// + /// Set to true to serialize ExecuteAsync calls across threads using an async-compatible mutex. + /// Default is false to allow concurrent executions when subclass is stateless or thread-safe. + /// + protected virtual bool Synchronized => false; + + /// + /// Executes the algorithm using the provided context and cancellation token. + /// Calls , then , then . + /// + public async Task ExecuteAsync(TContext context, CancellationToken cancellationToken = default) + { + if (Synchronized) + { + await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await OnBeforeAsync(context, cancellationToken).ConfigureAwait(false); + var result = await StepAsync(context, cancellationToken).ConfigureAwait(false); + await OnAfterAsync(context, result, cancellationToken).ConfigureAwait(false); + return result; + } + finally + { + _mutex.Release(); + } + } + + await OnBeforeAsync(context, cancellationToken).ConfigureAwait(false); + var res = await StepAsync(context, cancellationToken).ConfigureAwait(false); + await OnAfterAsync(context, res, cancellationToken).ConfigureAwait(false); + return res; + } + + /// + /// Optional async hook before the main step. + /// + protected virtual ValueTask OnBeforeAsync(TContext context, CancellationToken cancellationToken) => default; + + /// + /// The main async step of the algorithm. Must be implemented by subclasses. + /// + protected abstract ValueTask StepAsync(TContext context, CancellationToken cancellationToken); + + /// + /// Optional async hook after the main step. + /// + protected virtual ValueTask OnAfterAsync(TContext context, TResult result, CancellationToken cancellationToken) => default; +} \ No newline at end of file diff --git a/src/PatternKit.Core/Behavioral/Template/Template.cs b/src/PatternKit.Core/Behavioral/Template/Template.cs index 93f31b6..f6dd648 100644 --- a/src/PatternKit.Core/Behavioral/Template/Template.cs +++ b/src/PatternKit.Core/Behavioral/Template/Template.cs @@ -1,5 +1,3 @@ -using System; - namespace PatternKit.Behavioral.Template; /// diff --git a/src/PatternKit.Core/Behavioral/Template/TemplateMethod.cs b/src/PatternKit.Core/Behavioral/Template/TemplateMethod.cs index e2b75f8..21f4179 100644 --- a/src/PatternKit.Core/Behavioral/Template/TemplateMethod.cs +++ b/src/PatternKit.Core/Behavioral/Template/TemplateMethod.cs @@ -1,59 +1,56 @@ -using System; +namespace PatternKit.Behavioral.Template; -namespace PatternKit.Behavioral.Template +/// +/// Generic Template Method base class. +/// Defines the skeleton of an algorithm, allowing subclasses to override steps without changing the structure. +/// +/// The type of the context passed to the algorithm. +/// The type of the result produced by the algorithm. +public abstract class TemplateMethod { + private readonly object _sync = new object(); + /// - /// Generic Template Method base class. - /// Defines the skeleton of an algorithm, allowing subclasses to override steps without changing the structure. + /// Set to true to synchronize calls across threads. + /// Default is false to allow concurrent executions when subclass is stateless or thread-safe. /// - /// The type of the context passed to the algorithm. - /// The type of the result produced by the algorithm. - public abstract class TemplateMethod - { - private readonly object _sync = new object(); + protected virtual bool Synchronized => false; - /// - /// Set to true to synchronize calls across threads. - /// Default is false to allow concurrent executions when subclass is stateless or thread-safe. - /// - protected virtual bool Synchronized => false; - - /// - /// Executes the algorithm using the provided context. - /// Calls , then , then . - /// - public TResult Execute(TContext context) + /// + /// Executes the algorithm using the provided context. + /// Calls , then , then . + /// + public TResult Execute(TContext context) + { + if (Synchronized) { - if (Synchronized) + lock (_sync) { - lock (_sync) - { - OnBefore(context); - var result = Step(context); - OnAfter(context, result); - return result; - } + OnBefore(context); + var result = Step(context); + OnAfter(context, result); + return result; } - - OnBefore(context); - var res = Step(context); - OnAfter(context, res); - return res; } - /// - /// Optional hook before the main step. - /// - protected virtual void OnBefore(TContext context) { } + OnBefore(context); + var res = Step(context); + OnAfter(context, res); + return res; + } + + /// + /// Optional hook before the main step. + /// + protected virtual void OnBefore(TContext context) { } - /// - /// The main step of the algorithm. Must be implemented by subclasses. - /// - protected abstract TResult Step(TContext context); + /// + /// The main step of the algorithm. Must be implemented by subclasses. + /// + protected abstract TResult Step(TContext context); - /// - /// Optional hook after the main step. - /// - protected virtual void OnAfter(TContext context, TResult result) { } - } -} + /// + /// Optional hook after the main step. + /// + protected virtual void OnAfter(TContext context, TResult result) { } +} \ No newline at end of file diff --git a/src/PatternKit.Examples/ProxyDemo/ProxyDemo.cs b/src/PatternKit.Examples/ProxyDemo/ProxyDemo.cs index d8b05f7..7cbf85c 100644 --- a/src/PatternKit.Examples/ProxyDemo/ProxyDemo.cs +++ b/src/PatternKit.Examples/ProxyDemo/ProxyDemo.cs @@ -435,7 +435,7 @@ public static class MockFramework /// The mock records all invocations and allows verification of interactions, /// similar to popular mocking frameworks. /// - public sealed class Mock + public sealed class Mock where TIn : notnull { private readonly List<(Func predicate, TOut result)> _setups = new(); private readonly List _invocations = new(); @@ -529,7 +529,8 @@ public void VerifyAny(Func predicate) /// The input type. /// The output type. /// A new mock builder. - public static Mock CreateMock() => new(); + public static Mock CreateMock() where TIn : notnull + => new(); } /// diff --git a/src/PatternKit.Examples/TemplateDemo/TemplateAsyncDemo.cs b/src/PatternKit.Examples/TemplateDemo/TemplateAsyncDemo.cs new file mode 100644 index 0000000..6ee991a --- /dev/null +++ b/src/PatternKit.Examples/TemplateDemo/TemplateAsyncDemo.cs @@ -0,0 +1,75 @@ +using PatternKit.Behavioral.Template; + +namespace PatternKit.Examples.TemplateDemo; + +// Async subclassing demo: fetch → transform → store with cancellation, logging, and synchronization +public sealed class AsyncDataPipeline : AsyncTemplateMethod +{ + protected override bool Synchronized => false; // allow concurrency for independent requests + + protected override async ValueTask OnBeforeAsync(int requestId, CancellationToken cancellationToken) + { + Console.WriteLine($"[BeforeAsync] Request {requestId}: starting..."); + await Task.Yield(); + } + + protected override async ValueTask StepAsync(int requestId, CancellationToken cancellationToken) + { + // Simulate async fetch + await Task.Delay(25, cancellationToken); + var payload = $"payload:{requestId}"; + + // Simulate async transform + await Task.Delay(10, cancellationToken); + var transformed = payload.ToUpperInvariant(); + + // Simulate async store + await Task.Delay(5, cancellationToken); + return transformed; + } + + protected override async ValueTask OnAfterAsync(int requestId, string result, CancellationToken cancellationToken) + { + await Task.Yield(); + Console.WriteLine($"[AfterAsync] Request {requestId}: result='{result}'"); + } +} + +// Async fluent demo: same shape, with multiple hooks and error handling +public static class TemplateAsyncFluentDemo +{ + public static async Task RunAsync(CancellationToken cancellationToken = default) + { + var tpl = AsyncTemplate + .Create(async (id, ct) => + { + await Task.Delay(15, ct); + return id < 0 ? throw new InvalidOperationException("invalid id") : $"VAL-{id}"; + }) + .Before((id, _) => + { + Console.WriteLine($"[BeforeAsync] id={id}"); + return ValueTask.CompletedTask; + }) + .After((id, res, _) => + { + Console.WriteLine($"[AfterAsync] id={id}, res={res}"); + return ValueTask.CompletedTask; + }) + .OnError((id, err, _) => + { + Console.WriteLine($"[ErrorAsync] id={id}, err={err}"); + return ValueTask.CompletedTask; + }) + .Synchronized(false) // demonstrate opt-out + .Build(); + + // Success + var (ok1, res1, err1) = await tpl.TryExecuteAsync(42, cancellationToken); + Console.WriteLine(ok1 ? $"OK: {res1}" : $"ERR: {err1}"); + + // Failure + var (ok2, res2, err2) = await tpl.TryExecuteAsync(-1, cancellationToken); + Console.WriteLine(ok2 ? $"OK: {res2}" : $"ERR: {err2}"); + } +} \ No newline at end of file diff --git a/src/PatternKit.Examples/TemplateDemo/TemplateDemo.cs b/src/PatternKit.Examples/TemplateDemo/TemplateDemo.cs index ec734c2..fb8c6ec 100644 --- a/src/PatternKit.Examples/TemplateDemo/TemplateDemo.cs +++ b/src/PatternKit.Examples/TemplateDemo/TemplateDemo.cs @@ -1,37 +1,34 @@ -using System; using PatternKit.Behavioral.Template; -namespace PatternKit.Examples.TemplateDemo +namespace PatternKit.Examples.TemplateDemo; + +// Example: DataProcessor using TemplateMethod +public class DataProcessor : TemplateMethod { - // Example: DataProcessor using TemplateMethod - public class DataProcessor : TemplateMethod + protected override void OnBefore(string context) { - protected override void OnBefore(string context) - { - Console.WriteLine($"Preparing to process: {context}"); - } - - protected override int Step(string context) - { - // Simulate processing: count words - return context.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; - } + Console.WriteLine($"Preparing to process: {context}"); + } - protected override void OnAfter(string context, int result) - { - Console.WriteLine($"Processed '{context}' with result: {result}"); - } + protected override int Step(string context) + { + // Simulate processing: count words + return context.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; } - // Demo runner - public static class TemplateMethodDemo + protected override void OnAfter(string context, int result) { - public static void Run() - { - var processor = new DataProcessor(); - int result = processor.Execute("The quick brown fox jumps over the lazy dog"); - Console.WriteLine($"Word count: {result}"); - } + Console.WriteLine($"Processed '{context}' with result: {result}"); } } +// Demo runner +public static class TemplateMethodDemo +{ + public static void Run() + { + var processor = new DataProcessor(); + int result = processor.Execute("The quick brown fox jumps over the lazy dog"); + Console.WriteLine($"Word count: {result}"); + } +} \ No newline at end of file diff --git a/src/PatternKit.Examples/TemplateDemo/TemplateFluentDemo.cs b/src/PatternKit.Examples/TemplateDemo/TemplateFluentDemo.cs index e69de29..eddc0da 100644 --- a/src/PatternKit.Examples/TemplateDemo/TemplateFluentDemo.cs +++ b/src/PatternKit.Examples/TemplateDemo/TemplateFluentDemo.cs @@ -0,0 +1,21 @@ +using PatternKit.Behavioral.Template; + +namespace PatternKit.Examples.TemplateDemo; + +// Fluent Template demo: word count with logging and error handling +public static class TemplateFluentDemo +{ + public static void Run() + { + var tpl = Template + .Create(ctx => ctx.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length) + .Before(ctx => Console.WriteLine($"[Before] Input: '{ctx}'")) + .After((ctx, res) => Console.WriteLine($"[After] '{ctx}' -> words: {res}")) + .OnError((ctx, err) => Console.WriteLine($"[Error] Input '{ctx}', error: {err}")) + .Synchronized() // demonstrate optional synchronization + .Build(); + + var ok = tpl.TryExecute("The quick brown fox", out var result, out var error); + Console.WriteLine(ok ? $"Word count: {result}" : $"Failed: {error}"); + } +} \ No newline at end of file diff --git a/src/PatternKit.Generators/packages.lock.json b/src/PatternKit.Generators/packages.lock.json index 40a2a74..4eb9db9 100644 --- a/src/PatternKit.Generators/packages.lock.json +++ b/src/PatternKit.Generators/packages.lock.json @@ -117,194 +117,6 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.3" } } - }, - ".NETStandard,Version=v2.1": { - "Microsoft.CodeAnalysis.Analyzers": { - "type": "Direct", - "requested": "[3.11.0, )", - "resolved": "3.11.0", - "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" - }, - "Microsoft.CodeAnalysis.CSharp": { - "type": "Direct", - "requested": "[4.14.0, )", - "resolved": "4.14.0", - "contentHash": "568a6wcTivauIhbeWcCwfWwIn7UV7MeHEBvFB2uzGIpM2OhJ4eM/FZ8KS0yhPoNxnSpjGzz7x7CIjTxhslojQA==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.Common": "[4.14.0]", - "System.Buffers": "4.5.1", - "System.Collections.Immutable": "9.0.0", - "System.Memory": "4.5.5", - "System.Numerics.Vectors": "4.5.0", - "System.Reflection.Metadata": "9.0.0", - "System.Runtime.CompilerServices.Unsafe": "6.0.0", - "System.Text.Encoding.CodePages": "7.0.0", - "System.Threading.Tasks.Extensions": "4.5.4" - } - }, - "System.Collections.Immutable": { - "type": "Direct", - "requested": "[9.0.9, )", - "resolved": "9.0.9", - "contentHash": "/kpkgDxH984e3J3z5v/DIFi+0TWbUJXS8HNKUYBy3YnXtK09JVGs3cw5aOV6fDSw5NxbWLWlGrYjRteu6cjX3w==", - "dependencies": { - "System.Memory": "4.5.5", - "System.Runtime.CompilerServices.Unsafe": "6.0.0" - } - }, - "Microsoft.CodeAnalysis.Common": { - "type": "Transitive", - "resolved": "4.14.0", - "contentHash": "PC3tuwZYnC+idaPuoC/AZpEdwrtX7qFpmnrfQkgobGIWiYmGi5MCRtl5mx6QrfMGQpK78X2lfIEoZDLg/qnuHg==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "System.Buffers": "4.5.1", - "System.Collections.Immutable": "9.0.0", - "System.Memory": "4.5.5", - "System.Numerics.Vectors": "4.5.0", - "System.Reflection.Metadata": "9.0.0", - "System.Runtime.CompilerServices.Unsafe": "6.0.0", - "System.Text.Encoding.CodePages": "7.0.0", - "System.Threading.Tasks.Extensions": "4.5.4" - } - }, - "System.Buffers": { - "type": "Transitive", - "resolved": "4.5.1", - "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" - }, - "System.Memory": { - "type": "Transitive", - "resolved": "4.5.5", - "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", - "dependencies": { - "System.Buffers": "4.5.1", - "System.Numerics.Vectors": "4.4.0", - "System.Runtime.CompilerServices.Unsafe": "4.5.3" - } - }, - "System.Numerics.Vectors": { - "type": "Transitive", - "resolved": "4.5.0", - "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" - }, - "System.Reflection.Metadata": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "ANiqLu3DxW9kol/hMmTWbt3414t9ftdIuiIU7j80okq2YzAueo120M442xk1kDJWtmZTqWQn7wHDvMRipVOEOQ==", - "dependencies": { - "System.Collections.Immutable": "9.0.0", - "System.Memory": "4.5.5" - } - }, - "System.Runtime.CompilerServices.Unsafe": { - "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" - }, - "System.Text.Encoding.CodePages": { - "type": "Transitive", - "resolved": "7.0.0", - "contentHash": "LSyCblMpvOe0N3E+8e0skHcrIhgV2huaNcjUUEa8hRtgEAm36aGkRoC8Jxlb6Ra6GSfF29ftduPNywin8XolzQ==", - "dependencies": { - "System.Memory": "4.5.5", - "System.Runtime.CompilerServices.Unsafe": "6.0.0" - } - }, - "System.Threading.Tasks.Extensions": { - "type": "Transitive", - "resolved": "4.5.4", - "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", - "dependencies": { - "System.Runtime.CompilerServices.Unsafe": "4.5.3" - } - } - }, - "net8.0": { - "Microsoft.CodeAnalysis.Analyzers": { - "type": "Direct", - "requested": "[3.11.0, )", - "resolved": "3.11.0", - "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" - }, - "Microsoft.CodeAnalysis.CSharp": { - "type": "Direct", - "requested": "[4.14.0, )", - "resolved": "4.14.0", - "contentHash": "568a6wcTivauIhbeWcCwfWwIn7UV7MeHEBvFB2uzGIpM2OhJ4eM/FZ8KS0yhPoNxnSpjGzz7x7CIjTxhslojQA==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.Common": "[4.14.0]", - "System.Collections.Immutable": "9.0.0", - "System.Reflection.Metadata": "9.0.0" - } - }, - "System.Collections.Immutable": { - "type": "Direct", - "requested": "[9.0.9, )", - "resolved": "9.0.9", - "contentHash": "/kpkgDxH984e3J3z5v/DIFi+0TWbUJXS8HNKUYBy3YnXtK09JVGs3cw5aOV6fDSw5NxbWLWlGrYjRteu6cjX3w==" - }, - "Microsoft.CodeAnalysis.Common": { - "type": "Transitive", - "resolved": "4.14.0", - "contentHash": "PC3tuwZYnC+idaPuoC/AZpEdwrtX7qFpmnrfQkgobGIWiYmGi5MCRtl5mx6QrfMGQpK78X2lfIEoZDLg/qnuHg==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "System.Collections.Immutable": "9.0.0", - "System.Reflection.Metadata": "9.0.0" - } - }, - "System.Reflection.Metadata": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "ANiqLu3DxW9kol/hMmTWbt3414t9ftdIuiIU7j80okq2YzAueo120M442xk1kDJWtmZTqWQn7wHDvMRipVOEOQ==", - "dependencies": { - "System.Collections.Immutable": "9.0.0" - } - } - }, - "net9.0": { - "Microsoft.CodeAnalysis.Analyzers": { - "type": "Direct", - "requested": "[3.11.0, )", - "resolved": "3.11.0", - "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" - }, - "Microsoft.CodeAnalysis.CSharp": { - "type": "Direct", - "requested": "[4.14.0, )", - "resolved": "4.14.0", - "contentHash": "568a6wcTivauIhbeWcCwfWwIn7UV7MeHEBvFB2uzGIpM2OhJ4eM/FZ8KS0yhPoNxnSpjGzz7x7CIjTxhslojQA==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.Common": "[4.14.0]", - "System.Collections.Immutable": "9.0.0", - "System.Reflection.Metadata": "9.0.0" - } - }, - "System.Collections.Immutable": { - "type": "Direct", - "requested": "[9.0.9, )", - "resolved": "9.0.9", - "contentHash": "/kpkgDxH984e3J3z5v/DIFi+0TWbUJXS8HNKUYBy3YnXtK09JVGs3cw5aOV6fDSw5NxbWLWlGrYjRteu6cjX3w==" - }, - "Microsoft.CodeAnalysis.Common": { - "type": "Transitive", - "resolved": "4.14.0", - "contentHash": "PC3tuwZYnC+idaPuoC/AZpEdwrtX7qFpmnrfQkgobGIWiYmGi5MCRtl5mx6QrfMGQpK78X2lfIEoZDLg/qnuHg==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "System.Collections.Immutable": "9.0.0", - "System.Reflection.Metadata": "9.0.0" - } - }, - "System.Reflection.Metadata": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "ANiqLu3DxW9kol/hMmTWbt3414t9ftdIuiIU7j80okq2YzAueo120M442xk1kDJWtmZTqWQn7wHDvMRipVOEOQ==" - } } } } \ No newline at end of file diff --git a/test/PatternKit.Examples.Tests/AsyncStateDemo/AsyncStateDemoTests.cs b/test/PatternKit.Examples.Tests/AsyncStateDemo/AsyncStateDemoTests.cs index 5994de3..b65cc5e 100644 --- a/test/PatternKit.Examples.Tests/AsyncStateDemo/AsyncStateDemoTests.cs +++ b/test/PatternKit.Examples.Tests/AsyncStateDemo/AsyncStateDemoTests.cs @@ -1,11 +1,7 @@ using PatternKit.Examples.AsyncStateDemo; using TinyBDD; using TinyBDD.Xunit; -using Xunit; using Xunit.Abstractions; -using System.Linq; -using System; -using System.Threading.Tasks; namespace PatternKit.Examples.Tests.AsyncStateDemo; diff --git a/test/PatternKit.Examples.Tests/ProxyDemo/ConsoleOutputCollection.cs b/test/PatternKit.Examples.Tests/ProxyDemo/ConsoleOutputCollection.cs index 9f87065..281f54e 100644 --- a/test/PatternKit.Examples.Tests/ProxyDemo/ConsoleOutputCollection.cs +++ b/test/PatternKit.Examples.Tests/ProxyDemo/ConsoleOutputCollection.cs @@ -1,5 +1,3 @@ -using Xunit; - namespace PatternKit.Examples.Tests.ProxyDemo; // Collection to ensure tests that redirect Console output do not run in parallel. diff --git a/test/PatternKit.Examples.Tests/ProxyDemo/ProxyDemoParameterlessTests.cs b/test/PatternKit.Examples.Tests/ProxyDemo/ProxyDemoParameterlessTests.cs index 70f58b9..495f97e 100644 --- a/test/PatternKit.Examples.Tests/ProxyDemo/ProxyDemoParameterlessTests.cs +++ b/test/PatternKit.Examples.Tests/ProxyDemo/ProxyDemoParameterlessTests.cs @@ -1,9 +1,5 @@ -using System; -using System.IO; -using System.Linq; using TinyBDD; using TinyBDD.Xunit; -using Xunit; using Xunit.Abstractions; namespace PatternKit.Examples.Tests.ProxyDemo; diff --git a/test/PatternKit.Examples.Tests/StateDemo/StateDemoTests.cs b/test/PatternKit.Examples.Tests/StateDemo/StateDemoTests.cs index f39e698..de5bb3f 100644 --- a/test/PatternKit.Examples.Tests/StateDemo/StateDemoTests.cs +++ b/test/PatternKit.Examples.Tests/StateDemo/StateDemoTests.cs @@ -1,8 +1,6 @@ -using System.Linq; using PatternKit.Examples.StateDemo; using TinyBDD; using TinyBDD.Xunit; -using Xunit; using Xunit.Abstractions; namespace PatternKit.Examples.Tests.StateDemo; diff --git a/test/PatternKit.Examples.Tests/Strategies/Composed/ComposedStrategiesTests.Extended.cs b/test/PatternKit.Examples.Tests/Strategies/Composed/ComposedStrategiesTests.Extended.cs index 609bfd7..9e9aa19 100644 --- a/test/PatternKit.Examples.Tests/Strategies/Composed/ComposedStrategiesTests.Extended.cs +++ b/test/PatternKit.Examples.Tests/Strategies/Composed/ComposedStrategiesTests.Extended.cs @@ -4,248 +4,247 @@ using TinyBDD.Xunit; using Xunit.Abstractions; -namespace PatternKit.Examples.Tests.Strategies.Composed -{ - // Service spies to verify short-circuit behavior in Push/IM gates. - sealed class SpyIdentity : IIdentityService - { - public int HasPushTokenCalls; - public int HasVerifiedEmailCalls; - public int HasSmsOptInCalls; +namespace PatternKit.Examples.Tests.Strategies.Composed; - public bool PushToken; - public bool VerifiedEmail; - public bool SmsOptIn; +// 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 ValueTask HasVerifiedEmailAsync(Guid userId, CancellationToken ct) - { - HasVerifiedEmailCalls++; - return new(VerifiedEmail); - } + public bool PushToken; + public bool VerifiedEmail; + public bool SmsOptIn; - public ValueTask HasSmsOptInAsync(Guid userId, CancellationToken ct) - { - HasSmsOptInCalls++; - return new(SmsOptIn); - } + public ValueTask HasVerifiedEmailAsync(Guid userId, CancellationToken ct) + { + HasVerifiedEmailCalls++; + return new(VerifiedEmail); + } - public ValueTask HasPushTokenAsync(Guid userId, CancellationToken ct) - { - HasPushTokenCalls++; - return new(PushToken); - } + public ValueTask HasSmsOptInAsync(Guid userId, CancellationToken ct) + { + HasSmsOptInCalls++; + return new(SmsOptIn); } - sealed class SpyPresence : IPresenceService + public ValueTask HasPushTokenAsync(Guid userId, CancellationToken ct) { - public int OnlineCalls; - public int DndCalls; - public bool OnlineIm; - public bool DoNotDisturb; + HasPushTokenCalls++; + return new(PushToken); + } +} - public ValueTask IsOnlineInImAsync(Guid userId, CancellationToken ct) - { - OnlineCalls++; - return new(OnlineIm); - } +sealed class SpyPresence : IPresenceService +{ + public int OnlineCalls; + public int DndCalls; + public bool OnlineIm; + public bool DoNotDisturb; - public ValueTask IsDoNotDisturbAsync(Guid userId, CancellationToken ct) - { - DndCalls++; - return new(DoNotDisturb); - } + public ValueTask IsOnlineInImAsync(Guid userId, CancellationToken ct) + { + OnlineCalls++; + return new(OnlineIm); } - sealed class SpyRateLimiter : IRateLimiter + public ValueTask IsDoNotDisturbAsync(Guid userId, CancellationToken ct) { - public int EmailCalls; - public int SmsCalls; - public int PushCalls; - public int ImCalls; + 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 bool EmailAllowed = true; + public bool SmsAllowed = true; + public bool PushAllowed = true; + public bool ImAllowed = true; - public ValueTask CanSendAsync(Channel channel, Guid userId, CancellationToken ct) + public ValueTask CanSendAsync(Channel channel, Guid userId, CancellationToken ct) + { + switch (channel) { - 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); - } + 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 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")); - } +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); +[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", () => + // ---------- 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 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); + var r = await t.strategy.ExecuteAsync(Ctx(), CancellationToken.None); + return (t, r); }) - .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(); - } + .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", () => + // ---------- 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 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); + var r = await t.strategy.ExecuteAsync(Ctx(), CancellationToken.None); + return (t, r); }) - .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(); - } + .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", () => + // ---------- 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 { - 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) => + await strategy.ExecuteAsync(Ctx(), CancellationToken.None); + return (threw: false, ex: null); + } + catch (Exception ex) { - 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(); - } + 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]", () => + // ---------- 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 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); + var r = await t.strategy.ExecuteAsync(Ctx(), CancellationToken.None); + return (t, r); }) - .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(); - } + .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 index 12aabba..9618f64 100644 --- a/test/PatternKit.Examples.Tests/Strategies/Composed/ComposedStrategiesTests.cs +++ b/test/PatternKit.Examples.Tests/Strategies/Composed/ComposedStrategiesTests.cs @@ -4,410 +4,408 @@ using TinyBDD.Xunit; using Xunit.Abstractions; -namespace PatternKit.Examples.Tests.Strategies.Composed +namespace PatternKit.Examples.Tests.Strategies.Composed; +// ----------------- Fakes ----------------- + +sealed class FakeIdentity : IIdentityService { - // ----------------- Fakes ----------------- + 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; - sealed class FakeIdentity : IIdentityService + public ValueTask IsOnlineInImAsync(Guid userId, CancellationToken ct) { - public bool VerifiedEmail; - public bool SmsOptIn; - public bool PushToken; + OnlineImCalls++; + return new(OnlineIm); + } - 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); + public ValueTask IsDoNotDisturbAsync(Guid userId, CancellationToken ct) + { + DoNotDisturbCalls++; + return new(DoNotDisturb); } +} - sealed class FakePresence : IPresenceService +sealed class FakeRateLimiter : IRateLimiter +{ + private readonly Dictionary _allowed = new() { - public bool OnlineIm; - public bool DoNotDisturb; + [Channel.Email] = true, + [Channel.Sms] = true, + [Channel.Push] = true, + [Channel.Im] = true, + }; - public int OnlineImCalls; - public int DoNotDisturbCalls; + public void Set(Channel ch, bool allowed) => _allowed[ch] = allowed; - public ValueTask IsOnlineInImAsync(Guid userId, CancellationToken ct) - { - OnlineImCalls++; - return new(OnlineIm); - } + public ValueTask CanSendAsync(Channel channel, Guid userId, CancellationToken ct) + => new(_allowed.TryGetValue(channel, out var ok) && ok); +} - public ValueTask IsDoNotDisturbAsync(Guid userId, CancellationToken ct) - { - DoNotDisturbCalls++; - return new(DoNotDisturb); - } - } +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; - sealed class FakeRateLimiter : IRateLimiter + protected ValueTask CaptureAndReturn(SendContext ctx, CancellationToken ct, Channel ch) { - private readonly Dictionary _allowed = new() - { - [Channel.Email] = true, - [Channel.Sms] = true, - [Channel.Push] = true, - [Channel.Im] = true, - }; + Calls++; + LastContext = ctx; + return new(new SendResult(ch, ResultSuccess, Info)); + } +} - public void Set(Channel ch, bool allowed) => _allowed[ch] = allowed; +sealed class FakeEmailSender : CapturingSenderBase, IEmailSender +{ + public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => + CaptureAndReturn(ctx, ct, Channel.Email); +} - public ValueTask CanSendAsync(Channel channel, Guid userId, CancellationToken ct) - => new(_allowed.TryGetValue(channel, out var ok) && ok); - } +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 FakePrefs : IPreferenceService +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) { - private Channel[] _order = []; - public void Set(Channel[] order) => _order = order; - public ValueTask GetPreferredOrderAsync(Guid userId, CancellationToken ct) => new(_order); + 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; } - abstract class CapturingSenderBase + 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() { - public int Calls; - public SendContext? LastContext; - public bool ResultSuccess = true; - public string? Info; + 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(); + } - protected ValueTask CaptureAndReturn(SendContext ctx, CancellationToken ct, Channel ch) - { - Calls++; - LastContext = ctx; - return new(new SendResult(ch, ResultSuccess, Info)); - } + [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(); } - sealed class FakeEmailSender : CapturingSenderBase, IEmailSender + [Scenario("Critical: SMS is prepended regardless of prefs")] + [Fact] + public async Task Critical_Sms_First_RegardlessOfPrefs() { - public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => - CaptureAndReturn(ctx, ct, Channel.Email); + 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(); } - sealed class FakeSmsSender : CapturingSenderBase, ISmsSender + [Scenario("Critical but SMS not viable -> falls back to default Email send")] + [Fact] + public async Task Critical_Sms_NotViable_FallsBack_To_DefaultEmailSend() { - public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => - CaptureAndReturn(ctx, ct, Channel.Sms); + 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(); } - sealed class FakePushSender : CapturingSenderBase, IPushSender + [Scenario("Empty prefs -> defaults to Email")] + [Fact] + public async Task Prefs_Empty_Order_Defaults_To_Email() { - public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => - CaptureAndReturn(ctx, ct, Channel.Push); + 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(); } - sealed class FakeImSender : CapturingSenderBase, IImSender + [Scenario("Dedup: preserves first occurrence; attempts each gate once; sends next viable (Sms)")] + [Fact] + public async Task Dedup_Preserves_FirstOccurrence_AttemptsEachOnce() { - public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => - CaptureAndReturn(ctx, ct, Channel.Im); + 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(); } - // ----------------- BDD Tests ----------------- + [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(); + } - [Feature("Preference-aware composed strategies (TinyBDD)")] - public sealed class ComposedStrategiesBddTests(ITestOutputHelper output) : TinyBddXunitBase(output) + [Scenario("Push gate requires: token, not DND, and rate (progressive checks)")] + [Fact] + public async Task PushGate_RequiresToken_NotDnd_Rate_ThenPasses() { - 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() + // Start with Push preferred and Email verified for fallback + var baseHarness = CreateHarness(h => { - 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", () => { - 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() + 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 => { - 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", () => { - 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.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 => { - 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", () => + 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 => { - 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(); - } + 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.Tests/Behavioral/AsyncTemplateFluentTests.cs b/test/PatternKit.Tests/Behavioral/AsyncTemplateFluentTests.cs new file mode 100644 index 0000000..58b3757 --- /dev/null +++ b/test/PatternKit.Tests/Behavioral/AsyncTemplateFluentTests.cs @@ -0,0 +1,157 @@ +using System.Collections.Concurrent; +using PatternKit.Behavioral.Template; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Behavioral; + +[Feature("Behavioral - AsyncTemplate (fluent async skeleton with hooks)")] +public sealed class AsyncTemplateFluentTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("ExecuteAsync runs Before → Step → After in order")] + [Fact] + public Task ExecuteAsync_RunsHooks_InOrder() + => Given("a calls queue and an async template", () => + { + var calls = new ConcurrentQueue(); + var tpl = AsyncTemplate + .Create(async (ctx, ct) => + { + await Task.Delay(5, ct); + calls.Enqueue($"step:{ctx}"); + return ctx.Length; + }) + .Before((ctx, _) => + { + calls.Enqueue($"before:{ctx}"); + return default; + }) + .After((ctx, res, _) => + { + calls.Enqueue($"after:{ctx}:{res}"); + return default; + }) + .Build(); + return (tpl, calls); + }) + .When("executed with 'abc'", + (Func<(AsyncTemplate tpl, ConcurrentQueue calls), ValueTask<(int result, ConcurrentQueue calls)>>)(async x + => + { + var result = await x.tpl.ExecuteAsync("abc"); + return (result, x.calls); + })) + .Then("returns 3", r => r.result == 3) + .And("calls recorded in order", r => + { + var arr = r.calls.ToArray(); + return arr is ["before:abc", "step:abc", "after:abc:3"]; + }) + .AssertPassed(); + + [Scenario("TryExecuteAsync captures errors and invokes OnError")] + [Fact] + public Task TryExecuteAsync_Captures_Error() + => Given("a throwing template and an observer holder", () => + { + var holder = new string?[] { null }; + var tpl = AsyncTemplate + .Create((_, _) => ValueTask.FromException(new InvalidOperationException("boom"))) + .OnError((ctx, err, _) => + { + holder[0] = $"ctx={ctx};err={err}"; + return default; + }) + .Build(); + return (tpl, holder); + }) + .When("TryExecuteAsync with 42", + (Func<(AsyncTemplate tpl, string?[] holder), ValueTask<(bool ok, int result, string? error, string?[] holder)>>)(async x => + { + var tuple = await x.tpl.TryExecuteAsync(42); + var res = tuple.result; + return (tuple.ok, res, tuple.error, x.holder); + })) + .Then("returns false", r => !r.ok) + .And("error not null", r => r.error is { Length: > 0 }) + .And("OnError observed", r => r.holder[0] == "ctx=42;err=boom") + .AssertPassed(); + + [Scenario("Multiple Before/After hooks compose (multicast)")] + [Fact] + public Task Hooks_Compose_Multicast() + => Given("counter holder and async template", () => + { + var counts = new int[2]; // [0]=before, [1]=after + var tpl = AsyncTemplate + .Create((ctx, _) => ValueTask.FromResult(ctx.ToUpperInvariant())) + .Before((_, _) => + { + Interlocked.Increment(ref counts[0]); + return default; + }) + .Before((_, _) => + { + Interlocked.Increment(ref counts[0]); + return default; + }) + .After((_, _, _) => + { + Interlocked.Increment(ref counts[1]); + return default; + }) + .After((_, _, _) => + { + Interlocked.Increment(ref counts[1]); + return default; + }) + .Build(); + return (tpl, counts); + }) + .When("run twice concurrently", (Func<(AsyncTemplate tpl, int[] counts), ValueTask>)(async x => + { + var t1 = x.tpl.ExecuteAsync("x"); + var t2 = x.tpl.ExecuteAsync("y"); + await Task.WhenAll(t1, t2); + return x.counts; + })) + .Then("before called 4 times", counts => counts[0] == 4) + .And("after called 4 times", counts => counts[1] == 4) + .AssertPassed(); + + [Scenario("Synchronized enforces mutual exclusion")] + [Fact] + public Task Synchronized_Enforces_Mutex() + => Given("a synchronized async template and concurrency holder", () => + { + var holder = new int[2]; // [0]=concurrent, [1]=max + var tpl = AsyncTemplate + .Create(async (ctx, ct) => + { + var c = Interlocked.Increment(ref holder[0]); + while (true) + { + var snap = Volatile.Read(ref holder[1]); + var next = Math.Max(snap, c); + if (Interlocked.CompareExchange(ref holder[1], next, snap) == snap) break; + } + + await Task.Delay(20, ct); + Interlocked.Decrement(ref holder[0]); + return ctx * 2; + }) + .Synchronized() + .Build(); + return (tpl, holder); + }) + .When("executed on 8 tasks", (Func<(AsyncTemplate tpl, int[] holder), ValueTask<(int[] results, int[] holder)>>)(async x => + { + var tasks = Enumerable.Range(0, 8).Select(_ => x.tpl.ExecuteAsync(2)).ToArray(); + var results = await Task.WhenAll(tasks); + return (results, x.holder); + })) + .Then("all results are 4", r => r.results.All(v => v == 4)) + .And("max concurrency is 1", r => r.holder[1] == 1) + .AssertPassed(); +} \ No newline at end of file diff --git a/test/PatternKit.Tests/Behavioral/AsyncTemplateMethodTests.cs b/test/PatternKit.Tests/Behavioral/AsyncTemplateMethodTests.cs new file mode 100644 index 0000000..6ff94f8 --- /dev/null +++ b/test/PatternKit.Tests/Behavioral/AsyncTemplateMethodTests.cs @@ -0,0 +1,99 @@ +using PatternKit.Behavioral.Template; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Behavioral; + +[Feature("Behavioral - AsyncTemplateMethod (async skeleton with hooks)")] +public sealed class AsyncTemplateMethodTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + private sealed class SampleAsyncTemplate : AsyncTemplateMethod + { + public int BeforeCount; + public int AfterCount; + public int Concurrent; + public int MaxConcurrent; + private readonly int _delayMs; + private readonly bool _sync; + + public SampleAsyncTemplate(int delayMs = 10, bool sync = false) + { + _delayMs = delayMs; + _sync = sync; + } + + protected override bool Synchronized => _sync; + + protected override async ValueTask OnBeforeAsync(int context, CancellationToken cancellationToken) + { + Interlocked.Increment(ref BeforeCount); + var c = Interlocked.Increment(ref Concurrent); + Volatile.Write(ref MaxConcurrent, Math.Max(Volatile.Read(ref MaxConcurrent), c)); + await Task.Yield(); + } + + protected override async ValueTask StepAsync(int context, CancellationToken cancellationToken) + { + await Task.Delay(_delayMs, cancellationToken); + return $"R-{context}"; + } + + protected override async ValueTask OnAfterAsync(int context, string result, CancellationToken cancellationToken) + { + Interlocked.Decrement(ref Concurrent); + Interlocked.Increment(ref AfterCount); + await Task.Yield(); + } + } + + [Scenario("ExecuteAsync runs hooks and returns result")] + [Fact] + public Task ExecuteAsync_RunsHooks_And_Returns() + => Given("a sample async template", () => new SampleAsyncTemplate()) + .When("ExecuteAsync(7)", t => (tpl: t, res: t.ExecuteAsync(7).GetAwaiter().GetResult())) + .Then("result is R-7", r => r.res == "R-7") + .And("before called once", r => r.tpl.BeforeCount == 1) + .And("after called once", r => r.tpl.AfterCount == 1) + .AssertPassed(); + + [Scenario("Not synchronized allows concurrency")] + [Fact] + public Task Allows_Concurrency_When_Not_Synchronized() + => Given("a non-synchronized template with delay", () => new SampleAsyncTemplate(delayMs: 25, sync: false)) + .When("ExecuteAsync on 1..4 in parallel", t => + { + var tasks = new[] { t.ExecuteAsync(1), t.ExecuteAsync(2), t.ExecuteAsync(3), t.ExecuteAsync(4) }; + var results = Task.WhenAll(tasks).GetAwaiter().GetResult(); + return (tpl: t, results); + }) + .Then("contains R-1", r => r.results.Contains("R-1")) + .And("max concurrency > 1", r => r.tpl.MaxConcurrent > 1) + .AssertPassed(); + + [Scenario("Synchronized serializes ExecuteAsync calls")] + [Fact] + public Task Serializes_When_Synchronized() + => Given("a synchronized template with delay", () => new SampleAsyncTemplate(delayMs: 20, sync: true)) + .When("ExecuteAsync on 1..4 in parallel", t => + { + var tasks = new[] { t.ExecuteAsync(1), t.ExecuteAsync(2), t.ExecuteAsync(3), t.ExecuteAsync(4) }; + var results = Task.WhenAll(tasks).GetAwaiter().GetResult(); + return (tpl: t, results); + }) + .Then("contains R-1", r => r.results.Contains("R-1")) + .And("max concurrency == 1", r => r.tpl.MaxConcurrent == 1) + .AssertPassed(); + + [Scenario("Cancellation is observed")] + [Fact] + public Task Cancellation_Observed() + => Given("a template with long delay and a CTS", () => (tpl: new SampleAsyncTemplate(delayMs: 100), cts: new CancellationTokenSource(10))) + .When("ExecuteAsync with cancellation", ctx => + { + try { ctx.tpl.ExecuteAsync(1, ctx.cts.Token).GetAwaiter().GetResult(); return false; } + catch (OperationCanceledException) { return true; } + }) + .Then("throws OperationCanceledException", threw => threw) + .AssertPassed(); +} \ No newline at end of file diff --git a/test/PatternKit.Tests/Behavioral/Iterator/AsyncFlowTests.cs b/test/PatternKit.Tests/Behavioral/Iterator/AsyncFlowTests.cs index 6cac493..33e77ff 100644 --- a/test/PatternKit.Tests/Behavioral/Iterator/AsyncFlowTests.cs +++ b/test/PatternKit.Tests/Behavioral/Iterator/AsyncFlowTests.cs @@ -1,7 +1,6 @@ #if !NETSTANDARD2_0 using PatternKit.Behavioral.Iterator; using TinyBDD; -using TinyBDD.Assertions; using TinyBDD.Xunit; using Xunit.Abstractions; diff --git a/test/PatternKit.Tests/Behavioral/State/AsyncStateMachineTests.cs b/test/PatternKit.Tests/Behavioral/State/AsyncStateMachineTests.cs index 658af44..b6001c0 100644 --- a/test/PatternKit.Tests/Behavioral/State/AsyncStateMachineTests.cs +++ b/test/PatternKit.Tests/Behavioral/State/AsyncStateMachineTests.cs @@ -1,11 +1,7 @@ using PatternKit.Behavioral.State; using TinyBDD; using TinyBDD.Xunit; -using Xunit; using Xunit.Abstractions; -using System.Threading.Tasks; -using System.Linq; -using System.Collections.Generic; namespace PatternKit.Tests.Behavioral.State; diff --git a/test/PatternKit.Tests/Behavioral/State/StateMachineTests.cs b/test/PatternKit.Tests/Behavioral/State/StateMachineTests.cs index 874ff57..4ab9ac5 100644 --- a/test/PatternKit.Tests/Behavioral/State/StateMachineTests.cs +++ b/test/PatternKit.Tests/Behavioral/State/StateMachineTests.cs @@ -1,11 +1,7 @@ using PatternKit.Behavioral.State; using TinyBDD; using TinyBDD.Xunit; -using Xunit; using Xunit.Abstractions; -using System; -using System.Linq; -using System.Collections.Generic; namespace PatternKit.Tests.Behavioral.State; diff --git a/test/PatternKit.Tests/Behavioral/TemplateFluentTests.cs b/test/PatternKit.Tests/Behavioral/TemplateFluentTests.cs index 33d9495..f31e19c 100644 --- a/test/PatternKit.Tests/Behavioral/TemplateFluentTests.cs +++ b/test/PatternKit.Tests/Behavioral/TemplateFluentTests.cs @@ -1,96 +1,106 @@ -using System; using System.Collections.Concurrent; -using System.Threading; -using System.Threading.Tasks; using PatternKit.Behavioral.Template; -using Xunit; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; -namespace PatternKit.Tests.Behavioral -{ - public class TemplateFluentTests - { - [Fact] - public void Execute_RunsHooksAndStep_InOrder() - { - var calls = new ConcurrentQueue(); - var tpl = Template - .Create(ctx => { calls.Enqueue($"step:{ctx}"); return ctx.Length; }) - .Before(ctx => calls.Enqueue($"before:{ctx}")) - .After((ctx, res) => calls.Enqueue($"after:{ctx}:{res}")) - .Build(); - - var result = tpl.Execute("abc"); - - Assert.Equal(3, result); - Assert.Collection(calls, - s => Assert.Equal("before:abc", s), - s => Assert.Equal("step:abc", s), - s => Assert.Equal("after:abc:3", s)); - } - - [Fact] - public void TryExecute_CatchesErrors_AndInvokesOnError() - { - string? observed = null; - var tpl = Template - .Create(_ => throw new InvalidOperationException("boom")) - .OnError((ctx, err) => observed = $"ctx={ctx};err={err}") - .Build(); - - var ok = tpl.TryExecute(42, out var result, out var error); - - Assert.False(ok); - Assert.Equal(default, result); - Assert.NotNull(error); - Assert.Equal("ctx=42;err=boom", observed); - } +namespace PatternKit.Tests.Behavioral; - [Fact] - public async Task Hooks_Compose_Multicast() - { - int beforeCount = 0; - int afterCount = 0; - var tpl = Template - .Create(ctx => ctx.ToUpperInvariant()) - .Before(_ => Interlocked.Increment(ref beforeCount)) - .Before(_ => Interlocked.Increment(ref beforeCount)) - .After((_, _) => Interlocked.Increment(ref afterCount)) - .After((_, _) => Interlocked.Increment(ref afterCount)) - .Build(); - - var t1 = Task.Run(() => tpl.Execute("x")); - var t2 = Task.Run(() => tpl.Execute("y")); - await Task.WhenAll(t1, t2); - - Assert.Equal(4, beforeCount); - Assert.Equal(4, afterCount); - } - - [Fact] - public async Task Synchronized_EnforcesMutualExclusion() - { - int concurrent = 0; - int maxConcurrent = 0; - - var tpl = Template - .Create(ctx => - { - var c = Interlocked.Increment(ref concurrent); - maxConcurrent = Math.Max(maxConcurrent, c); - Thread.Sleep(20); - Interlocked.Decrement(ref concurrent); - return ctx * 2; - }) - .Synchronized() - .Build(); +[Feature("Behavioral - Template (fluent skeleton with hooks)")] +public sealed class TemplateFluentTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Execute runs Before → Step → After in order")] + [Fact] + public Task Execute_RunsHooks_InOrder() + => Given("a calls queue and a template", () => + { + var calls = new ConcurrentQueue(); + var tpl = Template + .Create(ctx => { calls.Enqueue($"step:{ctx}"); return ctx.Length; }) + .Before(ctx => calls.Enqueue($"before:{ctx}")) + .After((ctx, res) => calls.Enqueue($"after:{ctx}:{res}")) + .Build(); + return (tpl, calls); + }) + .When("executed with 'abc'", x => (result: x.tpl.Execute("abc"), x.calls)) + .Then("returns 3", r => r.result == 3) + .And("calls recorded in order", r => + { + var arr = r.Item2.ToArray(); + return arr.Length == 3 && arr[0] == "before:abc" && arr[1] == "step:abc" && arr[2] == "after:abc:3"; + }) + .AssertPassed(); - var tasks = new Task[8]; - for (int i = 0; i < tasks.Length; i++) tasks[i] = Task.Run(() => tpl.Execute(2)); - var results = await Task.WhenAll(tasks); + [Scenario("TryExecute captures errors and invokes OnError")] + [Fact] + public Task TryExecute_Captures_Error() + => Given("a throwing template and observer holder", () => + { + var holder = new { Observed = new string?[] { null } }; + var tpl = Template + .Create(_ => throw new InvalidOperationException("boom")) + .OnError((ctx, err) => holder.Observed[0] = $"ctx={ctx};err={err}") + .Build(); + return (tpl, holder); + }) + .When("TryExecute with 42", x => { var ok = x.tpl.TryExecute(42, out var result, out var error); return (ok, result, error, x.holder); }) + .Then("returns false", r => !r.ok) + .And("result is default", r => EqualityComparer.Default.Equals(r.result, default)) + .And("error not null", r => r.error is { Length: > 0 }) + .And("OnError observed", r => r.holder.Observed[0] == "ctx=42;err=boom") + .AssertPassed(); - Assert.All(results, r => Assert.Equal(4, r)); - Assert.Equal(1, maxConcurrent); - } - } -} + [Scenario("Multiple Before/After hooks compose (multicast)")] + [Fact] + public Task Hooks_Compose_Multicast() + => Given("counter holder and template", () => + { + var counts = new int[2]; // [0]=before, [1]=after + var tpl = Template + .Create(ctx => ctx.ToUpperInvariant()) + .Before(_ => Interlocked.Increment(ref counts[0])) + .Before(_ => Interlocked.Increment(ref counts[0])) + .After((_, _) => Interlocked.Increment(ref counts[1])) + .After((_, _) => Interlocked.Increment(ref counts[1])) + .Build(); + return (tpl, counts); + }) + .When("executed concurrently twice", x => { Task.WaitAll(Task.Run(() => x.tpl.Execute("x")), Task.Run(() => x.tpl.Execute("y"))); return x.counts; }) + .Then("before called 4 times", counts => counts[0] == 4) + .And("after called 4 times", counts => counts[1] == 4) + .AssertPassed(); + [Scenario("Synchronized enforces mutual exclusion")] + [Fact] + public Task Synchronized_Enforces_Mutex() + => Given("a synchronized template and concurrency holder", () => + { + var holder = new int[2]; // [0]=concurrent, [1]=max + var tpl = Template + .Create(ctx => + { + var c = Interlocked.Increment(ref holder[0]); + while (true) + { + var snap = Volatile.Read(ref holder[1]); + var next = Math.Max(snap, c); + if (Interlocked.CompareExchange(ref holder[1], next, snap) == snap) break; + } + Thread.Sleep(20); + Interlocked.Decrement(ref holder[0]); + return ctx * 2; + }) + .Synchronized() + .Build(); + return (tpl, holder); + }) + .When("executed on 8 tasks", x => + { + var tasks = Enumerable.Range(0, 8).Select(_ => Task.Run(() => x.tpl.Execute(2))).ToArray(); + Task.WaitAll(tasks); + return (results: tasks.Select(t => t.Result).ToArray(), x.holder); + }) + .Then("all results are 4", r => r.results.All(v => v == 4)) + .And("max concurrency is 1", r => r.holder[1] == 1) + .AssertPassed(); +} \ No newline at end of file diff --git a/test/PatternKit.Tests/Behavioral/TemplateMethodTests.cs b/test/PatternKit.Tests/Behavioral/TemplateMethodTests.cs index bb26368..7904ef0 100644 --- a/test/PatternKit.Tests/Behavioral/TemplateMethodTests.cs +++ b/test/PatternKit.Tests/Behavioral/TemplateMethodTests.cs @@ -1,58 +1,43 @@ -using System; -using System.Threading.Tasks; using PatternKit.Behavioral.Template; -using Xunit; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; -namespace PatternKit.Tests.Behavioral +namespace PatternKit.Tests.Behavioral; + +[Feature("Behavioral - TemplateMethod (skeleton with hooks)")] +public sealed class TemplateMethodTests(ITestOutputHelper output) : TinyBddXunitBase(output) { - public class TemplateMethodTests + private sealed class TestTemplate : TemplateMethod { - private class TestTemplate : TemplateMethod - { - public bool BeforeCalled { get; private set; } - public bool AfterCalled { get; private set; } - - protected override void OnBefore(string context) - { - BeforeCalled = true; - } - - protected override string Step(string context) - { - return context.ToUpperInvariant(); - } + public bool BeforeCalled { get; private set; } + public bool AfterCalled { get; private set; } - protected override void OnAfter(string context, string result) - { - AfterCalled = true; - } - } - - [Fact] - public void ExecutesAlgorithmAndHooks() - { - var template = new TestTemplate(); - var result = template.Execute("test"); - Assert.Equal("TEST", result); - Assert.True(template.BeforeCalled); - Assert.True(template.AfterCalled); - } - - [Fact] - public void IsThreadSafe() - { - var template = new TestTemplate(); - var tasks = new Task[10]; - for (int i = 0; i < 10; i++) - { - tasks[i] = Task.Run(() => template.Execute($"thread-{i}")); - } - Task.WaitAll(tasks); - foreach (var task in tasks) - { - Assert.StartsWith("THREAD-", task.Result); - } - } + protected override void OnBefore(string context) => BeforeCalled = true; + protected override string Step(string context) => context.ToUpperInvariant(); + protected override void OnAfter(string context, string result) => AfterCalled = true; } -} + [Scenario("Executes algorithm and before/after hooks in order")] + [Fact] + public Task Executes_Algorithm_And_Hooks() + => Given("a template instance", () => new TestTemplate()) + .When("executed with 'test'", t => (template: t, result: t.Execute("test"))) + .Then("returns transformed result", r => r.result == "TEST") + .And("before hook called", r => r.template.BeforeCalled) + .And("after hook called", r => r.template.AfterCalled) + .AssertPassed(); + + [Scenario("Concurrent executions are safe")] + [Fact] + public Task Is_Thread_Safe() + => Given("a template and 10 inputs", () => (tpl: new TestTemplate(), inputs: Enumerable.Range(0, 10).Select(i => $"thread-{i}").ToArray())) + .When("executed in parallel", ctx => + { + var results = new string[ctx.inputs.Length]; + Parallel.For(0, ctx.inputs.Length, i => results[i] = ctx.tpl.Execute(ctx.inputs[i])); + return results; + }) + .Then("all results upper-cased", results => results.All(s => s.StartsWith("THREAD-"))) + .AssertPassed(); +} \ No newline at end of file From 75c90bfd747e31d7ae12c6a7803e606a24316a41 Mon Sep 17 00:00:00 2001 From: "Jerrett D. Davis" Date: Tue, 14 Oct 2025 22:21:45 -0500 Subject: [PATCH 3/4] docs: documentation updates --- docs/examples/toc.yml | 6 + .../behavioral/template/asynctemplate.md | 106 +++++++++++++++ docs/patterns/behavioral/template/template.md | 101 ++++++++++++++ .../behavioral/template/templatemethod.md | 70 ++++++---- docs/patterns/index.md | 11 ++ docs/patterns/toc.yml | 125 ++++++++++++------ 6 files changed, 353 insertions(+), 66 deletions(-) create mode 100644 docs/patterns/behavioral/template/asynctemplate.md create mode 100644 docs/patterns/behavioral/template/template.md diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 606b22a..8b96b52 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -48,3 +48,9 @@ - name: Async Connection State Machine href: async-state-machine.md + +- name: Template Method (Subclassing) + href: template-method-demo.md + +- name: Template Method (Async) + href: template-method-async-demo.md diff --git a/docs/patterns/behavioral/template/asynctemplate.md b/docs/patterns/behavioral/template/asynctemplate.md new file mode 100644 index 0000000..90d693b --- /dev/null +++ b/docs/patterns/behavioral/template/asynctemplate.md @@ -0,0 +1,106 @@ +# AsyncTemplate + +A fluent, allocation-light async Template: define a fixed workflow (before → step → after), add async/sync hooks, opt into synchronization, and choose throwing or non-throwing execution. + +--- + +## What it is + +- Async skeleton with three phases: Before (0..n), Step (1), After (0..n) +- Non-throwing path via `TryExecuteAsync(context)` returning `(ok, result, error)` +- Optional per-instance synchronization via `SemaphoreSlim` +- Immutable and thread-safe after `Build()` + +--- + +## TL;DR + +```csharp +using PatternKit.Behavioral.Template; + +var tpl = AsyncTemplate + .Create(async (id, ct) => + { + await Task.Delay(15, ct); + if (id < 0) throw new InvalidOperationException("invalid id"); + return $"VAL-{id}"; + }) + .Before((id, ct) => { Console.WriteLine($"[BeforeAsync] {id}"); return default; }) + .After((id, res, ct) => { Console.WriteLine($"[AfterAsync] {id} -> {res}"); return default; }) + .OnError((id, err, ct) => { Console.WriteLine($"[ErrorAsync] {id}: {err}"); return default; }) + .Synchronized() // optional + .Build(); + +var (ok, result, error) = await tpl.TryExecuteAsync(42); +Console.WriteLine(ok ? $"OK: {result}" : $"ERR: {error}"); +``` + +--- + +## API shape + +```csharp +var tpl = AsyncTemplate + .Create(static (TContext ctx, CancellationToken ct) => /* ValueTask */) + .Before(static (TContext ctx, CancellationToken ct) => /* ValueTask */) // 0..n (async) + .Before(static (TContext ctx) => { /* side-effect */ }) // 0..n (sync overload) + .After(static (TContext ctx, TResult res, CancellationToken ct) => /* ValueTask */) // 0..n (async) + .After(static (TContext ctx, TResult res) => { /* side-effect */ }) // 0..n (sync overload) + .OnError(static (TContext ctx, string error, CancellationToken ct) => /* ValueTask */) // 0..n (async) + .OnError(static (TContext ctx, string error) => { /* observe */ }) // 0..n (sync overload) + .Synchronized() // optional + .Build(); + +// Throws on failure +TResult result = await tpl.ExecuteAsync(context, ct); + +// Non-throwing; returns tuple (ok, result?, error?) +(bool ok, TResult? result, string? error) = await tpl.TryExecuteAsync(context, ct); +``` + +Notes +- Multiple hooks compose; registration order is invocation order. +- OnError hooks run only when TryExecuteAsync catches an exception. +- Synchronized() uses an async mutex; keep the critical section small. + +--- + +## Testing (TinyBDD-style) + +```csharp +using PatternKit.Behavioral.Template; +using TinyBDD; +using TinyBDD.Xunit; + +var tpl = AsyncTemplate + .Create(async (ctx, ct) => { await Task.Yield(); return ctx.Length; }) + .Before((ctx, ct) => { Console.WriteLine($"before:{ctx}"); return default; }) + .After((ctx, res, ct) => { Console.WriteLine($"after:{ctx}:{res}"); return default; }) + .Build(); + +var r = await tpl.ExecuteAsync("abc"); // 3 +``` + +--- + +## Design notes + +- No reflection/LINQ in the hot path; simple async delegate calls and an optional async lock. +- Immutable after Build() so instances can be safely shared across threads. +- Sync and async hooks both supported; they are adapted internally to async. + +--- + +## Gotchas + +- ExecuteAsync throws; OnError hooks are not invoked by ExecuteAsync. +- TryExecuteAsync captures ex.Message as error; result is default when failing. +- Synchronized serializes executions; prefer idempotent, short steps. + +--- + +## See also + +- Subclassing: [TemplateMethod](./templatemethod.md) +- Synchronous fluent: [Template](./template.md) +- Demos: [Template Method Demo](../../../examples/template-method-demo.md), [Template Method Async Demo](../../../examples/template-method-async-demo.md) diff --git a/docs/patterns/behavioral/template/template.md b/docs/patterns/behavioral/template/template.md new file mode 100644 index 0000000..fa93b2a --- /dev/null +++ b/docs/patterns/behavioral/template/template.md @@ -0,0 +1,101 @@ +# Template + +A fluent, allocation-light Template: define a fixed workflow (before → step → after), add optional hooks, opt into synchronization, and choose throwing or non-throwing execution. + +--- + +## What it is + +- Skeleton with three phases: Before (0..n), Step (1), After (0..n) +- Non-throwing path via TryExecute(context, out result, out error) +- Optional per-instance synchronization (mutual exclusion) +- Immutable and thread-safe after Build() + +--- + +## TL;DR + +```csharp +using PatternKit.Behavioral.Template; + +var tpl = Template + .Create(ctx => ctx.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length) + .Before(ctx => Console.WriteLine($"[Before] '{ctx}'")) + .After((ctx, res) => Console.WriteLine($"[After] '{ctx}' -> {res}")) + .OnError((ctx, err) => Console.WriteLine($"[Error] '{ctx}': {err}")) + .Synchronized() // optional + .Build(); + +var ok = tpl.TryExecute("The quick brown fox", out var words, out var error); +Console.WriteLine(ok ? $"Words={words}" : $"Failed: {error}"); +``` + +--- + +## API shape + +```csharp +var tpl = Template + .Create(static (TContext ctx) => /* TResult */) + .Before(static (TContext ctx) => { /* side-effect */ }) // 0..n + .After(static (TContext ctx, TResult res) => { /* side-effect */ }) // 0..n + .OnError(static (TContext ctx, string error) => { /* observe */ }) // 0..n + .Synchronized() // optional + .Build(); + +// Execute throws on failure +TResult result = tpl.Execute(context); + +// TryExecute returns false and calls OnError hooks rather than throwing +bool ok = tpl.TryExecute(context, out TResult result, out string? error); +``` + +Notes +- Multiple Before/After/OnError hooks compose; registration order is call order. +- OnError hooks run only when TryExecute catches an exception. +- Synchronized() uses a per-instance lock; keep steps short to avoid contention. + +--- + +## Testing (TinyBDD-style) + +```csharp +using PatternKit.Behavioral.Template; +using TinyBDD; +using TinyBDD.Xunit; + +var (tpl, calls) = ( + Template + .Create(ctx => { calls.Enqueue($"step:{ctx}"); return ctx.Length; }) + .Before(ctx => calls.Enqueue($"before:{ctx}")) + .After((ctx, res) => calls.Enqueue($"after:{ctx}:{res}")) + .Build(), + new System.Collections.Concurrent.ConcurrentQueue()); + +var r = tpl.Execute("abc"); // 3 +// calls: before:abc, step:abc, after:abc:3 +``` + +--- + +## Design notes + +- No reflection/LINQ in the hot path; simple delegate invocation and optional lock. +- Immutable after Build() so instances can be safely shared across threads. +- Hooks are multicast; avoid heavy work inside hooks. + +--- + +## Gotchas + +- Execute throws; OnError hooks are not invoked on Execute. +- TryExecute returns default(TResult) on failure and captures ex.Message as error. +- Synchronized forces mutual exclusion; prefer idempotent, fast steps. + +--- + +## See also + +- Subclassing: [TemplateMethod](./templatemethod.md) +- Async fluent: [AsyncTemplate](./asynctemplate.md) +- Demos: [Template Method Demo](../../../examples/template-method-demo.md), [Template Method Async Demo](../../../examples/template-method-async-demo.md) diff --git a/docs/patterns/behavioral/template/templatemethod.md b/docs/patterns/behavioral/template/templatemethod.md index af67cab..062fa4c 100644 --- a/docs/patterns/behavioral/template/templatemethod.md +++ b/docs/patterns/behavioral/template/templatemethod.md @@ -2,7 +2,9 @@ **Category:** Behavioral -## Overview +--- + +## What it is Template Method defines the skeleton of an algorithm in a base class, allowing specific steps to be customized without changing the overall structure. PatternKit offers two complementary shapes: - Subclassing API: derive from `TemplateMethod` and override hooks. @@ -14,21 +16,13 @@ Common traits: - Optional synchronization for thread safety - Clear separation of “when/where” (hooks) and “what” (the main step) -## Structure -- `TemplateMethod` (abstract) - - `Execute(context)` — calls `OnBefore`, `Step`, `OnAfter` in order - - `protected virtual void OnBefore(context)` — optional pre-step hook - - `protected abstract TResult Step(context)` — required main step - - `protected virtual void OnAfter(context, result)` — optional post-step hook - - `protected virtual bool Synchronized` — set to `true` to serialize executions +--- -- `Template` (fluent) - - `Execute(context)` — runs before → step → after; throws on error - - `TryExecute(context, out result, out error)` — non-throwing path - - `Create(step)` → `.Before(...)` → `.After(...)` → `.OnError(...)` → `.Synchronized()` → `.Build()` +## TL;DR (subclassing) -## Subclassing Example ```csharp +using PatternKit.Behavioral.Template; + public sealed class DataProcessor : TemplateMethod { protected override void OnBefore(string context) @@ -48,8 +42,32 @@ var processor = new DataProcessor(); var count = processor.Execute("The quick brown fox"); ``` +--- + +## API shape (subclassing) + +- `TemplateMethod` (abstract) + - `TResult Execute(TContext context)` — calls `OnBefore`, `Step`, `OnAfter` in order + - `protected virtual void OnBefore(TContext context)` — optional pre-step hook + - `protected abstract TResult Step(TContext context)` — required main step + - `protected virtual void OnAfter(TContext context, TResult result)` — optional post-step hook + - `protected virtual bool Synchronized` — set to `true` to serialize executions + +- `AsyncTemplateMethod` (abstract) + - `Task ExecuteAsync(TContext context, CancellationToken ct)` — calls `OnBeforeAsync`, `StepAsync`, `OnAfterAsync` + - `protected virtual ValueTask OnBeforeAsync(TContext context, CancellationToken ct)` — optional pre-step hook + - `protected abstract ValueTask StepAsync(TContext context, CancellationToken ct)` — required main step + - `protected virtual ValueTask OnAfterAsync(TContext context, TResult result, CancellationToken ct)` — optional post-step hook + - `protected virtual bool Synchronized` — set to `true` to serialize `ExecuteAsync` calls (uses `SemaphoreSlim`) + +> Prefer the fluent siblings when you want multicast hooks, non-throwing `Try` execution, or quick composition: see [Template](./template.md) and [AsyncTemplate](./asynctemplate.md). + +--- + ## Fluent Example ```csharp +using PatternKit.Behavioral.Template; + var template = Template .Create(ctx => ctx.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length) .Before(ctx => Console.WriteLine($"[Before] '{ctx}'")) @@ -64,23 +82,15 @@ else Console.WriteLine($"Failed: {error}"); ``` +--- + ## Async variants PatternKit also provides first-class async variants with cancellation and optional synchronization: -- `AsyncTemplateMethod` (abstract) - - `ExecuteAsync(context, cancellationToken)` — calls `OnBeforeAsync`, `StepAsync`, `OnAfterAsync` in order - - `protected virtual ValueTask OnBeforeAsync(context, ct)` — optional pre-step hook - - `protected abstract ValueTask StepAsync(context, ct)` — required main step - - `protected virtual ValueTask OnAfterAsync(context, result, ct)` — optional post-step hook - - `protected virtual bool Synchronized` — set to `true` to serialize `ExecuteAsync` calls (uses `SemaphoreSlim`) - -- `AsyncTemplate` (fluent) - - `ExecuteAsync(context, ct)` — runs before → step → after; throws on error - - `TryExecuteAsync(context, ct)` — returns `(ok, result, error)` without throwing - - `Create(async (ctx, ct) => ...)` → `.Before(...)`/`.After(...)`/`.OnError(...)` (async or sync overloads) → `.Synchronized()` → `.Build()` - ### Async subclassing example ```csharp +using PatternKit.Behavioral.Template; + public sealed class AsyncDataPipeline : AsyncTemplateMethod { protected override bool Synchronized => false; // enable for strict serialization @@ -113,6 +123,8 @@ var outVal = await pipe.ExecuteAsync(42, cts.Token); ### Async fluent example ```csharp +using PatternKit.Behavioral.Template; + var tpl = AsyncTemplate .Create(async (id, ct) => { @@ -134,6 +146,8 @@ var (ok, result, error) = await tpl.TryExecuteAsync(42); - Use `.Synchronized()` or override `Synchronized` only when shared mutable state demands serialization. - Choose `TryExecuteAsync` when you need non-throwing control flow and centralized error observation. +--- + ## When to Use - You need a consistent workflow with customizable steps. - You want to prevent structural drift while enabling tailored behaviors. @@ -148,11 +162,15 @@ var (ok, result, error) = await tpl.TryExecuteAsync(42); - Subclassing: let exceptions bubble; catch externally if needed. - Fluent: use `TryExecute` to avoid throwing, and `.OnError(...)` to observe errors. +--- + ## Related Patterns - Strategy: swap entire algorithms rather than customizing steps inline. - Chain of Responsibility: linear rule packs with stop/continue semantics. - State: behavior that changes with state; Template Method keeps structure fixed. ## See Also +- Fluent (sync): [Template](./template.md) +- Fluent (async): [AsyncTemplate](./asynctemplate.md) +- Examples: [Template Method Demo](../../../examples/template-method-demo.md), [Template Method Async Demo](../../../examples/template-method-async-demo.md) - Refactoring Guru: Template Method — https://refactoring.guru/design-patterns/template-method -- Examples: see the Template Method demos in the Examples section. diff --git a/docs/patterns/index.md b/docs/patterns/index.md index b949971..0806527 100644 --- a/docs/patterns/index.md +++ b/docs/patterns/index.md @@ -42,6 +42,17 @@ If you’re looking for end-to-end, production-shaped demos, check the **Example - **[Behavioral.Strategy.AsyncStrategy](behavioral/strategy/asyncstrategy.md)** Async sibling for strategies that await external work. +### Template + +- **[Behavioral.Template.TemplateMethod](behavioral/template/templatemethod.md)** + Abstract base with `OnBefore/Step/OnAfter` hooks and optional synchronization. + +- **[Behavioral.Template.Template (Fluent)](behavioral/template/template.md)** + Fluent, allocation-light template with multicast hooks and `TryExecute`. + +- **[Behavioral.Template.AsyncTemplate (Fluent)](behavioral/template/asynctemplate.md)** + Async fluent sibling with cancellation-aware hooks and `TryExecuteAsync`. + ### Iterator - **[Behavioral.Iterator.ReplayableSequence](behavioral/iterator/replayablesequence.md)** diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index a4b8fe0..e188437 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -1,42 +1,87 @@ - - name: TemplateMethod - href: behavioral/template/templatemethod.md - - name: Creational +- name: Behavioral + items: + - name: Chain items: - - name: Builder - items: - - name: BranchBuilder - href: creational/builder/branchbuilder.md - - name: ChainBuilder - href: creational/builder/chainbuilder.md - - name: Composer - href: creational/builder/composer.md - - name: MutableBuilder - href: creational/builder/mutablebuilder.md - - name: Factory - items: - - name: Factory - href: creational/factory/factory.md - - name: Prototype - items: - - name: Prototype - href: creational/prototype/prototype.md - - name: Singleton - items: - - name: Singleton - href: creational/singleton/singleton.md - - name: Structural + - name: ActionChain + href: behavioral/chain/actionchain.md + - name: ResultChain + href: behavioral/chain/resultchain.md + - name: Strategy items: - - name: Adapter - href: structural/adapter/fluent-adapter.md - - name: Bridge - href: structural/bridge/bridge.md - - name: Composite - href: structural/composite/composite.md - - name: Decorator - href: structural/decorator/decorator.md - - name: Facade - href: structural/facade/facade.md - - name: Flyweight - href: structural/flyweight/index.md - - name: Proxy - href: structural/proxy/index.md + - name: Strategy + href: behavioral/strategy/strategy.md + - name: TryStrategy + href: behavioral/strategy/trystrategy.md + - name: ActionStrategy + href: behavioral/strategy/actionstrategy.md + - name: AsyncStrategy + href: behavioral/strategy/asyncstrategy.md + - name: Template + items: + - name: TemplateMethod + href: behavioral/template/templatemethod.md + - name: Template (Fluent) + href: behavioral/template/template.md + - name: AsyncTemplate (Fluent) + href: behavioral/template/asynctemplate.md + - name: Iterator + items: + - name: Flow + href: behavioral/iterator/flow.md + - name: AsyncFlow + href: behavioral/iterator/asyncflow.md + - name: ReplayableSequence + href: behavioral/iterator/replayablesequence.md + - name: WindowSequence + href: behavioral/iterator/windowsequence.md + - name: Mediator + href: behavioral/mediator/mediator.md + - name: Command + href: behavioral/command/command.md + - name: Observer + items: + - name: Observer + href: behavioral/observer/observer.md + - name: AsyncObserver + href: behavioral/observer/asyncobserver.md + - name: State + href: behavioral/state/state.md + - name: Memento + href: behavioral/memento/memento.md + +- name: Creational + items: + - name: Builder + items: + - name: BranchBuilder + href: creational/builder/branchbuilder.md + - name: ChainBuilder + href: creational/builder/chainbuilder.md + - name: Composer + href: creational/builder/composer.md + - name: MutableBuilder + href: creational/builder/mutablebuilder.md + - name: Factory + href: creational/factory/factory.md + - name: Prototype + href: creational/prototype/prototype.md + - name: Singleton + href: creational/singleton/singleton.md + +- name: Structural + items: + - name: Adapter + href: structural/adapter/fluent-adapter.md + - name: Bridge + href: structural/bridge/bridge.md + - name: Composite + href: structural/composite/composite.md + - name: Decorator + href: structural/decorator/decorator.md + - name: Facade + href: structural/facade/facade.md + - name: Flyweight + href: structural/flyweight/index.md + - name: Proxy + href: structural/proxy/index.md + From fe3a162928da8c9abb8e49b6a94d993e53abd359 Mon Sep 17 00:00:00 2001 From: "Jerrett D. Davis" Date: Mon, 27 Oct 2025 20:45:27 -0500 Subject: [PATCH 4/4] chore: minor code cleanup --- .../Behavioral/Iterator/AsyncFlow.cs | 2 +- .../Behavioral/Template/Template.cs | 3 ++ .../Structural/Flyweight/Flyweight.cs | 4 +-- .../AsyncStateDemo/AsyncStateDemo.cs | 24 ++++++------- .../FlyweightDemo/FlyweightDemo.cs | 2 +- .../StateDemo/StateDemo.cs | 34 +++++++++---------- .../AsyncStateDemo/AsyncStateDemoTests.cs | 14 ++++---- .../ObserverDemo/ReactiveTransactionTests.cs | 2 +- .../ProxyDemo/ProxyDemoTests.cs | 10 +++--- .../Behavioral/Iterator/AsyncFlowTests.cs | 2 +- .../State/AsyncStateMachineTests.cs | 20 +++++------ .../Behavioral/State/StateMachineTests.cs | 24 ++++++------- .../Structural/Facade/TypedFacadeTests.cs | 2 +- 13 files changed, 73 insertions(+), 70 deletions(-) diff --git a/src/PatternKit.Core/Behavioral/Iterator/AsyncFlow.cs b/src/PatternKit.Core/Behavioral/Iterator/AsyncFlow.cs index 99a5c5a..a713b64 100644 --- a/src/PatternKit.Core/Behavioral/Iterator/AsyncFlow.cs +++ b/src/PatternKit.Core/Behavioral/Iterator/AsyncFlow.cs @@ -103,7 +103,7 @@ public async ValueTask TryGetAsync(int index, CancellationToken ct) if (index < 0) return false; while (true) { - TaskCompletionSource? waiter = null; + TaskCompletionSource? waiter; lock (_sync) { if (index < _buffer.Count) diff --git a/src/PatternKit.Core/Behavioral/Template/Template.cs b/src/PatternKit.Core/Behavioral/Template/Template.cs index f6dd648..bc17c89 100644 --- a/src/PatternKit.Core/Behavioral/Template/Template.cs +++ b/src/PatternKit.Core/Behavioral/Template/Template.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace PatternKit.Behavioral.Template; /// @@ -32,6 +34,7 @@ private Template(BeforeHook? before, StepHook step, AfterHook? after, ErrorHook? /// /// Execute the template: before → step → after. Throws on errors. /// + [SuppressMessage("ReSharper", "InconsistentlySynchronizedField")] public TResult Execute(TContext context) { if (_synchronized) diff --git a/src/PatternKit.Core/Structural/Flyweight/Flyweight.cs b/src/PatternKit.Core/Structural/Flyweight/Flyweight.cs index 04ff141..f6577c7 100644 --- a/src/PatternKit.Core/Structural/Flyweight/Flyweight.cs +++ b/src/PatternKit.Core/Structural/Flyweight/Flyweight.cs @@ -76,8 +76,8 @@ private Flyweight(Dictionary cache, Factory factory) /// /// Uses a double-checked locking pattern: /// - /// Fast path: try dictionary without lock. - /// Slow path: lock, re-check, then create & store. + /// Fast path: try a dictionary without lock. + /// Slow path: lock, re-check, then create, and store. /// /// Subsequent calls for the same key take the fast path. /// diff --git a/src/PatternKit.Examples/AsyncStateDemo/AsyncStateDemo.cs b/src/PatternKit.Examples/AsyncStateDemo/AsyncStateDemo.cs index a098817..4d89553 100644 --- a/src/PatternKit.Examples/AsyncStateDemo/AsyncStateDemo.cs +++ b/src/PatternKit.Examples/AsyncStateDemo/AsyncStateDemo.cs @@ -12,24 +12,24 @@ public enum Mode { Disconnected, Connecting, Connected, Error } var log = new List(); var m = AsyncStateMachine.Create() .InState(Mode.Disconnected, s => s - .OnExit(async (e, ct) => { await Task.Yield(); log.Add("exit:Disconnected"); }) - .When((e, ct) => new ValueTask(e.Kind == "connect")).Permit(Mode.Connecting).Do(async (e, ct) => { await Task.Delay(1, ct); log.Add("effect:dial"); }) + .OnExit(async (_, _) => { await Task.Yield(); log.Add("exit:Disconnected"); }) + .When((e, _) => new ValueTask(e.Kind == "connect")).Permit(Mode.Connecting).Do(async (_, ct) => { await Task.Delay(1, ct); log.Add("effect:dial"); }) ) .InState(Mode.Connecting, s => s - .OnEnter(async (e, ct) => { await Task.Yield(); log.Add("enter:Connecting"); }) - .OnExit(async (e, ct) => { await Task.Yield(); log.Add("exit:Connecting"); }) - .When((e, ct) => new ValueTask(e.Kind == "ok")).Permit(Mode.Connected).Do(async (e, ct) => { await Task.Yield(); log.Add("effect:handshake"); }) - .When((e, ct) => new ValueTask(e.Kind == "fail")).Permit(Mode.Error).Do(async (e, ct) => { await Task.Yield(); log.Add("effect:cleanup"); }) - .When((e, ct) => new ValueTask(e.Kind == "cancel")).Permit(Mode.Disconnected).Do(async (e, ct) => { await Task.Yield(); log.Add("effect:cancel"); }) + .OnEnter(async (_, _) => { await Task.Yield(); log.Add("enter:Connecting"); }) + .OnExit(async (_, _) => { await Task.Yield(); log.Add("exit:Connecting"); }) + .When((e, _) => new ValueTask(e.Kind == "ok")).Permit(Mode.Connected).Do(async (_, _) => { await Task.Yield(); log.Add("effect:handshake"); }) + .When((e, _) => new ValueTask(e.Kind == "fail")).Permit(Mode.Error).Do(async (_, _) => { await Task.Yield(); log.Add("effect:cleanup"); }) + .When((e, _) => new ValueTask(e.Kind == "cancel")).Permit(Mode.Disconnected).Do(async (_, _) => { await Task.Yield(); log.Add("effect:cancel"); }) ) .InState(Mode.Connected, s => s - .OnEnter(async (e, ct) => { await Task.Yield(); log.Add("enter:Connected"); }) - .When((e, ct) => new ValueTask(e.Kind == "drop")).Permit(Mode.Connecting).Do(async (e, ct) => { await Task.Yield(); log.Add("effect:reconnect"); }) - .Otherwise().Stay().Do(async (e, ct) => { await Task.Yield(); log.Add("effect:noop"); }) + .OnEnter(async (_, _) => { await Task.Yield(); log.Add("enter:Connected"); }) + .When((e, _) => new ValueTask(e.Kind == "drop")).Permit(Mode.Connecting).Do(async (_, _) => { await Task.Yield(); log.Add("effect:reconnect"); }) + .Otherwise().Stay().Do(async (_, _) => { await Task.Yield(); log.Add("effect:noop"); }) ) .InState(Mode.Error, s => s - .OnEnter(async (e, ct) => { await Task.Yield(); log.Add("enter:Error"); }) - .Otherwise().Stay().Do(async (e, ct) => { await Task.Yield(); log.Add("effect:noop"); }) + .OnEnter(async (_, _) => { await Task.Yield(); log.Add("enter:Error"); }) + .Otherwise().Stay().Do(async (_, _) => { await Task.Yield(); log.Add("effect:noop"); }) ) .Build(); diff --git a/src/PatternKit.Examples/FlyweightDemo/FlyweightDemo.cs b/src/PatternKit.Examples/FlyweightDemo/FlyweightDemo.cs index ae166d3..8f02522 100644 --- a/src/PatternKit.Examples/FlyweightDemo/FlyweightDemo.cs +++ b/src/PatternKit.Examples/FlyweightDemo/FlyweightDemo.cs @@ -3,7 +3,7 @@ namespace PatternKit.Examples.FlyweightDemo; /// -/// Demonstrations for the Flyweight pattern: glyph layout & style sharing. +/// Demonstrations for the Flyweight pattern: glyph layout, and style sharing. /// public static class FlyweightDemo { diff --git a/src/PatternKit.Examples/StateDemo/StateDemo.cs b/src/PatternKit.Examples/StateDemo/StateDemo.cs index a6af4cd..b02ac71 100644 --- a/src/PatternKit.Examples/StateDemo/StateDemo.cs +++ b/src/PatternKit.Examples/StateDemo/StateDemo.cs @@ -12,33 +12,33 @@ public static (OrderState Final, List Log) Run(params string[] events) var log = new List(); var machine = StateMachine.Create() .InState(OrderState.New, s => s - .OnExit((in OrderEvent _) => log.Add("audit:new->")) - .When(static (in OrderEvent e) => e.Kind == "pay").Permit(OrderState.Paid).Do((in OrderEvent _) => log.Add("charge")) - .When(static (in OrderEvent e) => e.Kind == "cancel").Permit(OrderState.Cancelled).Do((in OrderEvent _) => log.Add("cancel")) + .OnExit((in _) => log.Add("audit:new->")) + .When(static (in e) => e.Kind == "pay").Permit(OrderState.Paid).Do((in _) => log.Add("charge")) + .When(static (in e) => e.Kind == "cancel").Permit(OrderState.Cancelled).Do((in _) => log.Add("cancel")) ) .InState(OrderState.Paid, s => s - .OnEnter((in OrderEvent _) => log.Add("notify:paid")) - .OnExit((in OrderEvent _) => log.Add("audit:paid->")) - .When(static (in OrderEvent e) => e.Kind == "ship").Permit(OrderState.Shipped).Do((in OrderEvent _) => log.Add("ship")) - .When(static (in OrderEvent e) => e.Kind == "cancel").Permit(OrderState.Cancelled).Do((in OrderEvent _) => log.Add("cancel")) + .OnEnter((in _) => log.Add("notify:paid")) + .OnExit((in _) => log.Add("audit:paid->")) + .When(static (in e) => e.Kind == "ship").Permit(OrderState.Shipped).Do((in _) => log.Add("ship")) + .When(static (in e) => e.Kind == "cancel").Permit(OrderState.Cancelled).Do((in _) => log.Add("cancel")) ) .InState(OrderState.Shipped, s => s - .OnEnter((in OrderEvent _) => log.Add("notify:shipped")) - .OnExit((in OrderEvent _) => log.Add("audit:shipped->")) - .When(static (in OrderEvent e) => e.Kind == "deliver").Permit(OrderState.Delivered).Do((in OrderEvent _) => log.Add("deliver")) + .OnEnter((in _) => log.Add("notify:shipped")) + .OnExit((in _) => log.Add("audit:shipped->")) + .When(static (in e) => e.Kind == "deliver").Permit(OrderState.Delivered).Do((in _) => log.Add("deliver")) ) .InState(OrderState.Delivered, s => s - .OnEnter((in OrderEvent _) => log.Add("notify:delivered")) - .Otherwise().Stay().Do((in OrderEvent _) => log.Add("ignore")) + .OnEnter((in _) => log.Add("notify:delivered")) + .Otherwise().Stay().Do((in _) => log.Add("ignore")) ) .InState(OrderState.Cancelled, s => s - .OnEnter((in OrderEvent _) => log.Add("notify:cancelled")) - .When(static (in OrderEvent e) => e.Kind == "refund").Permit(OrderState.Refunded).Do((in OrderEvent _) => log.Add("refund")) - .Otherwise().Stay().Do((in OrderEvent _) => log.Add("ignore")) + .OnEnter((in _) => log.Add("notify:cancelled")) + .When(static (in e) => e.Kind == "refund").Permit(OrderState.Refunded).Do((in _) => log.Add("refund")) + .Otherwise().Stay().Do((in _) => log.Add("ignore")) ) .InState(OrderState.Refunded, s => s - .OnEnter((in OrderEvent _) => log.Add("notify:refunded")) - .Otherwise().Stay().Do((in OrderEvent _) => log.Add("ignore")) + .OnEnter((in _) => log.Add("notify:refunded")) + .Otherwise().Stay().Do((in _) => log.Add("ignore")) ) .Build(); diff --git a/test/PatternKit.Examples.Tests/AsyncStateDemo/AsyncStateDemoTests.cs b/test/PatternKit.Examples.Tests/AsyncStateDemo/AsyncStateDemoTests.cs index b65cc5e..fbe0c11 100644 --- a/test/PatternKit.Examples.Tests/AsyncStateDemo/AsyncStateDemoTests.cs +++ b/test/PatternKit.Examples.Tests/AsyncStateDemo/AsyncStateDemoTests.cs @@ -11,10 +11,10 @@ public sealed class AsyncStateDemoTests(ITestOutputHelper output) : TinyBddXunit [Fact] public async Task Connect_Flow() { - await Given("async connection demo", () => default(object?)) - .When<(ConnectionStateDemo.Mode Final, System.Collections.Generic.List Log)>( + await Given("async connection demo", () => default(object?)) + .When<(ConnectionStateDemo.Mode Final, List Log)>( "run connect, ok", - (Func)>>)(_ => ConnectionStateDemo.RunAsync("connect", "ok").AsTask()) + (Func)>>)(_ => ConnectionStateDemo.RunAsync("connect", "ok").AsTask()) ) .Then("final Connected and logs show exit/eff/enter handover", r => r.Final == ConnectionStateDemo.Mode.Connected && @@ -29,13 +29,13 @@ public async Task Connect_Flow() [Fact] public async Task Default_Stay_NoOp() { - await Given<(ConnectionStateDemo.Mode Final, System.Collections.Generic.List Log)>( + await Given<(ConnectionStateDemo.Mode Final, List Log)>( "connected", - (Func)>>)(() => ConnectionStateDemo.RunAsync("connect","ok").AsTask()) + (Func)>>)(() => ConnectionStateDemo.RunAsync("connect","ok").AsTask()) ) - .When<(ConnectionStateDemo.Mode Final, System.Collections.Generic.List Log)>( + .When<(ConnectionStateDemo.Mode Final, List Log)>( "unknown event in Connected", - (Func<(ConnectionStateDemo.Mode, System.Collections.Generic.List), Task<(ConnectionStateDemo.Mode, System.Collections.Generic.List)>>)( + (Func<(ConnectionStateDemo.Mode, List), Task<(ConnectionStateDemo.Mode, List)>>)( _ => ConnectionStateDemo.RunAsync("connect","ok","ignore").AsTask() ) ) diff --git a/test/PatternKit.Examples.Tests/ObserverDemo/ReactiveTransactionTests.cs b/test/PatternKit.Examples.Tests/ObserverDemo/ReactiveTransactionTests.cs index 2b0639a..1589e47 100644 --- a/test/PatternKit.Examples.Tests/ObserverDemo/ReactiveTransactionTests.cs +++ b/test/PatternKit.Examples.Tests/ObserverDemo/ReactiveTransactionTests.cs @@ -17,7 +17,7 @@ private sealed record Ctx(ReactiveTransaction Tx, List Totals) private static Ctx SubscribeTotals(Ctx c) { - c.Tx.Total.Subscribe((old, @new) => c.Totals.Add(@new)); + c.Tx.Total.Subscribe((_, @new) => c.Totals.Add(@new)); return c; } diff --git a/test/PatternKit.Examples.Tests/ProxyDemo/ProxyDemoTests.cs b/test/PatternKit.Examples.Tests/ProxyDemo/ProxyDemoTests.cs index 2a813e4..bd4c539 100644 --- a/test/PatternKit.Examples.Tests/ProxyDemo/ProxyDemoTests.cs +++ b/test/PatternKit.Examples.Tests/ProxyDemo/ProxyDemoTests.cs @@ -216,7 +216,7 @@ public Task CachingProxy_Demo_Validation() => Given("caching proxy with fibonacci", () => { var callCount = 0; - var proxy = PatternKit.Structural.Proxy.Proxy.Create(n => + var proxy = Proxy.Create(n => { callCount++; // Simple fibonacci @@ -252,7 +252,7 @@ public Task LoggingProxy_Demo_Validation() => Given("logging proxy with list", () => { var logs = new List(); - var proxy = PatternKit.Structural.Proxy.Proxy<(int a, int b), int>.Create( + var proxy = Proxy<(int a, int b), int>.Create( input => input.a + input.b) .LoggingProxy(logs.Add) .Build(); @@ -279,7 +279,7 @@ public Task RemoteProxy_Demo_Validation() var logs = new List(); // Inner proxy with logging - var innerProxy = PatternKit.Structural.Proxy.Proxy.Create(id => + var innerProxy = Proxy.Create(id => { callCount++; return $"Remote data for ID {id}"; @@ -294,7 +294,7 @@ public Task RemoteProxy_Demo_Validation() .Build(); // Outer caching proxy - var cachedProxy = PatternKit.Structural.Proxy.Proxy.Create( + var cachedProxy = Proxy.Create( id => innerProxy.Execute(id)) .CachingProxy() .Build(); @@ -319,7 +319,7 @@ public Task RetryInterceptor_Demo_Validation() => Given("proxy with retry logic", () => { var attempts = 0; - var proxy = PatternKit.Structural.Proxy.Proxy.Create(req => + var proxy = Proxy.Create(req => { attempts++; if (attempts < 3) diff --git a/test/PatternKit.Tests/Behavioral/Iterator/AsyncFlowTests.cs b/test/PatternKit.Tests/Behavioral/Iterator/AsyncFlowTests.cs index 33e77ff..8499335 100644 --- a/test/PatternKit.Tests/Behavioral/Iterator/AsyncFlowTests.cs +++ b/test/PatternKit.Tests/Behavioral/Iterator/AsyncFlowTests.cs @@ -56,7 +56,7 @@ private static async IAsyncEnumerable DuplicateAsync(int v) return (even, odd); } - private static async Task<(PatternKit.Common.Option Some, PatternKit.Common.Option None)> Firsts((AsyncFlow NonEmpty, AsyncFlow Empty) flows) + private static async Task<(Common.Option Some, Common.Option None)> Firsts((AsyncFlow NonEmpty, AsyncFlow Empty) flows) { var some = await flows.NonEmpty.FirstOptionAsync(); var none = await flows.Empty.FirstOptionAsync(); diff --git a/test/PatternKit.Tests/Behavioral/State/AsyncStateMachineTests.cs b/test/PatternKit.Tests/Behavioral/State/AsyncStateMachineTests.cs index b6001c0..c30c59e 100644 --- a/test/PatternKit.Tests/Behavioral/State/AsyncStateMachineTests.cs +++ b/test/PatternKit.Tests/Behavioral/State/AsyncStateMachineTests.cs @@ -17,20 +17,20 @@ private static Ctx Build() var log = new List(); var m = AsyncStateMachine.Create() .InState(S.Idle, s => s - .OnExit(async (e, ct) => { await Task.Yield(); log.Add("exit:Idle"); }) - .When((e, ct) => new ValueTask(e.Kind == "go")).Permit(S.Active).Do(async (e, ct) => { await Task.Delay(1, ct); log.Add("effect:go"); }) - .When((e, ct) => new ValueTask(e.Kind == "panic")).Permit(S.Alarm).Do(async (e, ct) => { await Task.Yield(); log.Add("effect:panic"); }) + .OnExit(async (_, _) => { await Task.Yield(); log.Add("exit:Idle"); }) + .When((e, _) => new ValueTask(e.Kind == "go")).Permit(S.Active).Do(async (_, ct) => { await Task.Delay(1, ct); log.Add("effect:go"); }) + .When((e, _) => new ValueTask(e.Kind == "panic")).Permit(S.Alarm).Do(async (_, _) => { await Task.Yield(); log.Add("effect:panic"); }) ) .InState(S.Active, s => s - .OnEnter(async (e, ct) => { await Task.Yield(); log.Add("enter:Active"); }) - .OnExit(async (e, ct) => { await Task.Yield(); log.Add("exit:Active"); }) - .When((e, ct) => new ValueTask(e.Kind == "ping")).Stay().Do(async (e, ct) => { await Task.Yield(); log.Add("effect:ping"); }) - .When((e, ct) => new ValueTask(e.Kind == "stop")).Permit(S.Idle).Do(async (e, ct) => { await Task.Yield(); log.Add("effect:stop"); }) + .OnEnter(async (_, _) => { await Task.Yield(); log.Add("enter:Active"); }) + .OnExit(async (_, _) => { await Task.Yield(); log.Add("exit:Active"); }) + .When((e, _) => new ValueTask(e.Kind == "ping")).Stay().Do(async (_, _) => { await Task.Yield(); log.Add("effect:ping"); }) + .When((e, _) => new ValueTask(e.Kind == "stop")).Permit(S.Idle).Do(async (_, _) => { await Task.Yield(); log.Add("effect:stop"); }) ) .InState(S.Alarm, s => s - .OnEnter(async (e, ct) => { await Task.Yield(); log.Add("enter:Alarm"); }) - .When((e, ct) => new ValueTask(e.Kind == "reset")).Permit(S.Idle).Do(async (e, ct) => { await Task.Yield(); log.Add("effect:reset"); }) - .Otherwise().Stay().Do(async (e, ct) => { await Task.Yield(); log.Add("effect:default"); }) + .OnEnter(async (_, _) => { await Task.Yield(); log.Add("enter:Alarm"); }) + .When((e, _) => new ValueTask(e.Kind == "reset")).Permit(S.Idle).Do(async (_, _) => { await Task.Yield(); log.Add("effect:reset"); }) + .Otherwise().Stay().Do(async (_, _) => { await Task.Yield(); log.Add("effect:default"); }) ) .Build(); return new Ctx(m, log, S.Idle); diff --git a/test/PatternKit.Tests/Behavioral/State/StateMachineTests.cs b/test/PatternKit.Tests/Behavioral/State/StateMachineTests.cs index 4ab9ac5..d98de4c 100644 --- a/test/PatternKit.Tests/Behavioral/State/StateMachineTests.cs +++ b/test/PatternKit.Tests/Behavioral/State/StateMachineTests.cs @@ -19,20 +19,20 @@ private static Ctx BuildCtx() var log = new List(); var m = StateMachine.Create() .InState(S.Idle, s => s - .OnExit((in Ev _) => log.Add("exit:Idle")) - .When(static (in Ev e) => Is("go", in e)).Permit(S.Active).Do((in Ev _) => log.Add("effect:go")) - .When(static (in Ev e) => Is("panic", in e)).Permit(S.Alarm).Do((in Ev _) => log.Add("effect:panic")) + .OnExit((in _) => log.Add("exit:Idle")) + .When(static (in e) => Is("go", in e)).Permit(S.Active).Do((in _) => log.Add("effect:go")) + .When(static (in e) => Is("panic", in e)).Permit(S.Alarm).Do((in _) => log.Add("effect:panic")) ) .InState(S.Active, s => s - .OnEnter((in Ev _) => log.Add("enter:Active")) - .OnExit((in Ev _) => log.Add("exit:Active")) - .When(static (in Ev e) => Is("ping", in e)).Stay().Do((in Ev _) => log.Add("effect:ping")) - .When(static (in Ev e) => Is("stop", in e)).Permit(S.Idle).Do((in Ev _) => log.Add("effect:stop")) + .OnEnter((in _) => log.Add("enter:Active")) + .OnExit((in _) => log.Add("exit:Active")) + .When(static (in e) => Is("ping", in e)).Stay().Do((in _) => log.Add("effect:ping")) + .When(static (in e) => Is("stop", in e)).Permit(S.Idle).Do((in _) => log.Add("effect:stop")) ) .InState(S.Alarm, s => s - .OnEnter((in Ev _) => log.Add("enter:Alarm")) - .When(static (in Ev e) => Is("reset", in e)).Permit(S.Idle).Do((in Ev _) => log.Add("effect:reset")) - .Otherwise().Stay().Do((in Ev _) => log.Add("effect:default")) + .OnEnter((in _) => log.Add("enter:Alarm")) + .When(static (in e) => Is("reset", in e)).Permit(S.Idle).Do((in _) => log.Add("effect:reset")) + .Otherwise().Stay().Do((in _) => log.Add("effect:default")) ) .Build(); return new Ctx(m, log, S.Idle); @@ -104,8 +104,8 @@ public async Task First_Match_Wins() var log = new List(); var m = StateMachine.Create() .InState(S.Idle, s => s - .When(static (in Ev e) => e.Kind.Length > 0).Stay().Do((in Ev _) => log.Add("first")) - .When(static (in Ev e) => e.Kind == "x").Permit(S.Active).Do((in Ev _) => log.Add("second")) + .When(static (in e) => e.Kind.Length > 0).Stay().Do((in _) => log.Add("first")) + .When(static (in e) => e.Kind == "x").Permit(S.Active).Do((in _) => log.Add("second")) ) .Build(); diff --git a/test/PatternKit.Tests/Structural/Facade/TypedFacadeTests.cs b/test/PatternKit.Tests/Structural/Facade/TypedFacadeTests.cs index 20fc2e9..3616c9a 100644 --- a/test/PatternKit.Tests/Structural/Facade/TypedFacadeTests.cs +++ b/test/PatternKit.Tests/Structural/Facade/TypedFacadeTests.cs @@ -129,7 +129,7 @@ public Task TypedFacade_Varying_Parameters() service.Operation4(1.5m, 2.5m, 3.5m, 4.5m))) .Then("1-param method works", r => r.Item1 == "Result: test") .And("2-param method works", r => r.Item2 == 15) - .And("3-param method works", r => r.Item3 == true) + .And("3-param method works", r => r.Item3) .And("4-param method works", r => r.Item4 == 12.0m) .AssertPassed();