diff --git a/README.md b/README.md index 5e969f9..f0b02c2 100644 --- a/README.md +++ b/README.md @@ -450,4 +450,4 @@ var cachedRemoteProxy = Proxy.Create(id => remoteProxy.Execute(id)) | -------------- |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | **Creational** | [Factory](docs/patterns/creational/factory/factory.md) ✓ • [Composer](docs/patterns/creational/builder/composer.md) ✓ • [ChainBuilder](docs/patterns/creational/builder/chainbuilder.md) ✓ • [BranchBuilder](docs/patterns/creational/builder/chainbuilder.md) ✓ • [MutableBuilder](docs/patterns/creational/builder/mutablebuilder.md) ✓ • [Prototype](docs/patterns/creational/prototype/prototype.md) ✓ • [Singleton](docs/patterns/creational/singleton/singleton.md) ✓ | | **Structural** | [Adapter](docs/patterns/structural/adapter/fluent-adapter.md) ✓ • [Bridge](docs/patterns/structural/bridge/bridge.md) ✓ • [Composite](docs/patterns/structural/composite/composite.md) ✓ • [Decorator](docs/patterns/structural/decorator/decorator.md) ✓ • [Facade](docs/patterns/structural/facade/facade.md) ✓ • [Flyweight](docs/patterns/structural/flyweight/index.md) ✓ • [Proxy](docs/patterns/structural/proxy/index.md) ✓ | -| **Behavioral** | [Strategy](docs/patterns/behavioral/strategy/strategy.md) ✓ • [TryStrategy](docs/patterns/behavioral/strategy/trystrategy.md) ✓ • [ActionStrategy](docs/patterns/behavioral/strategy/actionstrategy.md) ✓ • [ActionChain](docs/patterns/behavioral/chain/actionchain.md) ✓ • [ResultChain](docs/patterns/behavioral/chain/resultchain.md) ✓ • [ReplayableSequence](docs/patterns/behavioral/iterator/replayablesequence.md) ✓ • [WindowSequence](docs/patterns/behavioral/iterator/windowsequence.md) ✓ • [Command](docs/patterns/behavioral/command/command.md) ✓ • [Mediator](docs/patterns/behavioral/mediator/mediator.md) ✓ • [Memento](docs/patterns/behavioral/memento/memento.md) ✓ • Observer (planned) • State (planned) • Template Method (planned) • Visitor (planned) | +| **Behavioral** | [Strategy](docs/patterns/behavioral/strategy/strategy.md) ✓ • [TryStrategy](docs/patterns/behavioral/strategy/trystrategy.md) ✓ • [ActionStrategy](docs/patterns/behavioral/strategy/actionstrategy.md) ✓ • [ActionChain](docs/patterns/behavioral/chain/actionchain.md) ✓ • [ResultChain](docs/patterns/behavioral/chain/resultchain.md) ✓ • [ReplayableSequence](docs/patterns/behavioral/iterator/replayablesequence.md) ✓ • [WindowSequence](docs/patterns/behavioral/iterator/windowsequence.md) ✓ • [Command](docs/patterns/behavioral/command/command.md) ✓ • [Mediator](docs/patterns/behavioral/mediator/mediator.md) ✓ • [Memento](docs/patterns/behavioral/memento/memento.md) ✓ • [Observer](docs/patterns/behavioral/observer/observer.md) ✓ • [State](docs/patterns/behavioral/state/state.md) ✓ • Template Method (planned) • Visitor (planned) | diff --git a/docs/examples/async-state-machine.md b/docs/examples/async-state-machine.md new file mode 100644 index 0000000..4b70dc6 --- /dev/null +++ b/docs/examples/async-state-machine.md @@ -0,0 +1,130 @@ +# Async State Machine — Connection Lifecycle + +A small, production‑shaped demo of AsyncStateMachine managing a connection lifecycle. It shows async entry/exit hooks, async effects, and default stay behavior. + +What you’ll see +- Declarative states and transitions using When → Permit/Stay → Do. +- Exit → Effect → Enter order on cross‑state transitions. +- Default Stay that ignores unknown events while connected. + +Code (excerpt) +```csharp +using PatternKit.Behavioral.State; + +public static class ConnectionStateDemo +{ + public enum Mode { Disconnected, Connecting, Connected, Error } + public readonly record struct NetEvent(string Kind); + + public static async ValueTask<(Mode Final, List Log)> RunAsync(params string[] events) + { + 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"); }) + ) + .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"); }) + ) + .InState(Mode.Connected, s => s + .OnEnter(async (e, ct) => { await Task.Yield(); log.Add("enter:Connected"); }) + .Otherwise().Stay().Do(async (e, ct) => { await Task.Yield(); log.Add("effect:noop"); }) + ) + .Build(); + + var state = Mode.Disconnected; + foreach (var k in events) + { + var (_, next) = await m.TryTransitionAsync(state, new NetEvent(k)); + state = next; + } + return (state, log); + } +} +``` + +How to run +```bat +rem Build everything (Debug) +dotnet build PatternKit.slnx -c Debug + +rem Run tests (includes this demo) +dotnet test PatternKit.slnx -c Debug --filter FullyQualifiedName~AsyncStateDemo +``` + +Relevant files +- src/PatternKit.Examples/AsyncStateDemo/AsyncStateDemo.cs +- test/PatternKit.Examples.Tests/AsyncStateDemo/AsyncStateDemoTests.cs +- docs/patterns/behavioral/state/state.md — pattern reference (sync + async) +# State Machine (Sync and Async) + +A fluent, allocation‑light state machine with entry/exit hooks, optional default transitions, and a predictable first‑match rule order. Ships in two forms: +- StateMachine — synchronous delegates +- AsyncStateMachine — async predicates/effects/hooks + +Design notes +- Immutable after Build(); share safely across threads. +- You hold the current state; pass it to the machine per call. +- First matching When rule in registration order wins; optional per‑state Otherwise default. +- Exit → Effect → Enter ordering for cross‑state transitions; Stay executes effect only. + +Quick start (sync) +```csharp +var m = 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")) + ) + .InState(OrderState.Paid, s => s + .OnEnter((in OrderEvent _) => log.Add("notify:paid")) + .When(static (in OrderEvent e) => e.Kind == "ship").Permit(OrderState.Shipped).Do((in OrderEvent _) => log.Add("ship")) + ) + .Build(); + +var state = OrderState.New; +m.TryTransition(ref state, in new OrderEvent("pay")); +``` + +Quick start (async) +```csharp +var m = AsyncStateMachine.Create() + .InState(State.Idle, s => s + .OnExit(async (e, ct) => { await Task.Yield(); log.Add("exit:Idle"); }) + .When((e, ct) => new ValueTask(e.Kind == "go")).Permit(State.Active).Do(async (e, ct) => { await Task.Delay(1, ct); log.Add("effect:go"); }) + ) + .InState(State.Active, s => s + .OnEnter(async (e, ct) => { await Task.Yield(); log.Add("enter:Active"); }) + ) + .Build(); + +var (handled, next) = await m.TryTransitionAsync(State.Idle, new Ev("go")); +``` + +Semantics +- Cross‑state: exit hooks of current, then effect, then entry hooks of next. +- Stay/internal: effect only; entry/exit hooks do not run. +- Default per state: .Otherwise().Permit(...) or .Otherwise().Stay() when no predicate matches. +- Comparer: supply a custom equality comparer via .Comparer(...) in the builder when needed. + +API summary +- StateMachine + - bool TryTransition(ref TState state, in TEvent @event) + - void Transition(ref TState state, in TEvent @event) +- AsyncStateMachine + - ValueTask<(bool handled, TState state)> TryTransitionAsync(TState state, TEvent @event, CancellationToken ct = default) + - ValueTask TransitionAsync(TState state, TEvent @event, CancellationToken ct = default) + +Examples +- docs/examples/state-machine.md — order lifecycle (sync) +- docs/examples/async-state-machine.md — connection lifecycle (async) + +Tips +- Prefer enums or interned strings for TState; or pass a comparer. +- Keep predicates cheap; effects are a good place for side‑effects. +- Async flavor avoids ref parameters by returning the updated state. + diff --git a/docs/examples/index.md b/docs/examples/index.md index 5888a73..ca8f29a 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -43,6 +43,9 @@ Welcome! This section collects small, focused demos that show **how to compose b * **Document Editing History (Memento)** A simple document buffer with tagged snapshots, undo/redo, jump-to-version, and duplicate suppression illustrating the **Memento** pattern's practical shape in a UI/editor workflow. +* **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. + ## How to run From the repo root: @@ -81,6 +84,7 @@ dotnet test PatternKit.slnx -c Release * **Flyweight Glyph Cache:** `FlyweightDemo` (+ `FlyweightDemoTests`) — glyph width layout & style sharing. * **Flyweight Structural Tests:** `Structural/Flyweight/FlyweightTests.cs` — preload, concurrency, comparer, guards. * **Memento Document Demo:** `MementoDemo` — buffer edits with undo/redo and tags. +* **State Machine Demo:** `OrderStateDemo` (+ `StateDemoTests`) — order lifecycle with entry/exit hooks and default behaviors. * **Tests:** `PatternKit.Examples.Tests/*` use TinyBDD scenarios that read like specs. ## Why these demos exist diff --git a/docs/examples/state-machine.md b/docs/examples/state-machine.md new file mode 100644 index 0000000..ebc68d9 --- /dev/null +++ b/docs/examples/state-machine.md @@ -0,0 +1,92 @@ +# State Machine — Order Lifecycle + +A small, production‑shaped demo of a fluent State Machine managing an order’s lifecycle. It shows entry/exit hooks, transition effects, and default per‑state behavior. + +What you’ll see +- Declarative states and transitions using When → Permit/Stay → Do. +- Entry/exit hooks for notifications and audits. +- Default stay transitions that “ignore” unknown events in terminal states. +- Immutable machine shared across calls; you pass state by ref. + +--- +## Code +```csharp +using PatternKit.Behavioral.State; + +public static class OrderStateDemo +{ + public enum OrderState { New, Paid, Shipped, Delivered, Cancelled, Refunded } + public readonly record struct OrderEvent(string Kind); + + 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")) + ) + .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")) + ) + .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")) + ) + .InState(OrderState.Delivered, s => s + .OnEnter((in OrderEvent _) => log.Add("notify:delivered")) + .Otherwise().Stay().Do((in OrderEvent _) => 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")) + ) + .InState(OrderState.Refunded, s => s + .OnEnter((in OrderEvent _) => log.Add("notify:refunded")) + .Otherwise().Stay().Do((in OrderEvent _) => log.Add("ignore")) + ) + .Build(); + + var state = OrderState.New; + foreach (var k in events) + machine.TryTransition(ref state, in new OrderEvent(k)); + + return (state, log); + } +} +``` + +--- +## Behavior +- pay → ship → deliver takes you New → Paid → Shipped → Delivered. +- cancel → refund takes you New → Cancelled → Refunded. +- Delivered ignores further events using a default Stay() rule. + +--- +## How to run +From the repo root: + +```bash +rem Build everything (Release) +dotnet build PatternKit.slnx -c Release + +rem Run tests for examples (includes this demo) +dotnet test PatternKit.slnx -c Release --filter FullyQualifiedName~StateDemo +``` + +Relevant files +- src/PatternKit.Examples/StateDemo/StateDemo.cs — the demo machine. +- test/PatternKit.Examples.Tests/StateDemo/StateDemoTests.cs — specs for paths and logs. +- docs/patterns/behavioral/state/state.md — the pattern reference. + +--- +## Tips +- Entry/exit hooks run on cross‑state transitions only (not Stay()). +- Effects run between exit and entry; useful for audit/side‑effects. +- Prefer enums or interned strings for TState when identity matters; you can also supply a custom comparer via .Comparer(...). + diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 77dfe87..606b22a 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -45,3 +45,6 @@ - name: Reactive Transaction (Dynamic Discounts, Tax, Total) href: reactive-transaction.md + +- name: Async Connection State Machine + href: async-state-machine.md diff --git a/docs/patterns/behavioral/state/state.md b/docs/patterns/behavioral/state/state.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/patterns/index.md b/docs/patterns/index.md index 995ab22..b949971 100644 --- a/docs/patterns/index.md +++ b/docs/patterns/index.md @@ -8,13 +8,13 @@ If you’re looking for end-to-end, production-shaped demos, check the **Example ## How these fit together -* **Behavioral** patterns describe *what runs & when* (chains and strategies). -* **Creational** helpers build immutable, fast artifacts (routers, pipelines) from tiny delegates. -* Common themes: +- Behavioral patterns describe *what runs & when* (chains and strategies, observers, mediators, state machines). +- Creational helpers build immutable, fast artifacts (routers, pipelines) from tiny delegates. +- Common themes: - * **First-match wins** (predictable branching without `if` ladders). - * **Branchless rule packs** (`ActionChain` with `When/ThenContinue/ThenStop/Finally`). - * **Immutable after `Build()`** (thread-safe, allocation-light hot paths). + - **First-match wins** (predictable branching without `if` ladders). + - **Branchless rule packs** (`ActionChain` with `When/ThenContinue/ThenStop/Finally`). + - **Immutable after `Build()`** (thread-safe, allocation-light hot paths). --- @@ -22,77 +22,108 @@ If you’re looking for end-to-end, production-shaped demos, check the **Example ### Chain -* **[Behavioral.Chain.ActionChain](behavioral/chain/actionchain.md)** +- **[Behavioral.Chain.ActionChain](behavioral/chain/actionchain.md)** Compose linear rule packs with explicit continue/stop semantics and an always-runs `Finally`. -* **[Behavioral.Chain.ResultChain](behavioral/chain/resultchain.md)** +- **[Behavioral.Chain.ResultChain](behavioral/chain/resultchain.md)** Like `ActionChain`, but each step returns a result; first failure short-circuits. ### Strategy -* **[Behavioral.Strategy.Strategy](behavioral/strategy/strategy.md)** +- **[Behavioral.Strategy.Strategy](behavioral/strategy/strategy.md)** Simple strategy selection—pick exactly one handler. -* **[Behavioral.Strategy.TryStrategy](behavioral/strategy/trystrategy.md)** +- **[Behavioral.Strategy.TryStrategy](behavioral/strategy/trystrategy.md)** First-success wins: chain of `Try(in, out)` handlers; great for parsing/coercion. -* **[Behavioral.Strategy.ActionStrategy](behavioral/strategy/actionstrategy.md)** +- **[Behavioral.Strategy.ActionStrategy](behavioral/strategy/actionstrategy.md)** Fire one or more actions (no result value) based on predicates. -* **[Behavioral.Strategy.AsyncStrategy](behavioral/strategy/asyncstrategy.md)** +- **[Behavioral.Strategy.AsyncStrategy](behavioral/strategy/asyncstrategy.md)** Async sibling for strategies that await external work. ### Iterator -* **[Behavioral.Iterator.ReplayableSequence](behavioral/iterator/replayablesequence.md)** +- **[Behavioral.Iterator.ReplayableSequence](behavioral/iterator/replayablesequence.md)** Forkable, lookahead, on-demand buffered sequence with immutable struct cursors, speculative forks, and LINQ interop (pay-as-you-go buffering). -* **[Behavioral.Iterator.WindowSequence](behavioral/iterator/windowsequence.md)** +- **[Behavioral.Iterator.WindowSequence](behavioral/iterator/windowsequence.md)** Sliding / striding window iterator with optional partial trailing window and buffer reuse for zero-allocation full windows. +### Mediator + +- **[Behavioral.Mediator.Mediator](behavioral/mediator/mediator.md)** + +### Command + +- **[Behavioral.Command.Command](behavioral/command/command.md)** + +### Observer + +- **[Behavioral.Observer.Observer](behavioral/observer/observer.md)** +- **[Behavioral.Observer.AsyncObserver](behavioral/observer/asyncobserver.md)** + +### State + +- **[Behavioral.State.StateMachine](behavioral/state/state.md)** + Fluent, generic state machine with entry/exit hooks and transition effects; immutable after `Build()`. + --- ## Creational (Builder) -* **[Creational.Builder.BranchBuilder](creational/builder/branchbuilder.md)** +- **[Creational.Builder.BranchBuilder](creational/builder/branchbuilder.md)** Zero-`if` router: register `(predicate → step)` pairs; emits a tight first-match loop. -* **[Creational.Builder.ChainBuilder](creational/builder/chainbuilder.md)** +- **[Creational.Builder.ChainBuilder](creational/builder/chainbuilder.md)** Small helper to accumulate steps, then project into your own pipeline type. -* **[Creational.Builder.Composer](creational/builder/composer.md)** +- **[Creational.Builder.Composer](creational/builder/composer.md)** Compose multiple builders/artifacts into a single product. -* **[Creational.Builder.MutableBuilder](creational/builder/mutablebuilder.md)** +- **[Creational.Builder.MutableBuilder](creational/builder/mutablebuilder.md)** A lightweight base for fluent, mutable configuration objects. -* **[Creational.Factory](creational/factory/factory.md)** +- **[Creational.Factory](creational/factory/factory.md)** Key → creator mapping with optional default; immutable and allocation-light. -* **[Creational.Prototype](creational/prototype/prototype.md)** +- **[Creational.Prototype](creational/prototype/prototype.md)** Clone-and-tweak via fluent builders and keyed registries. -* **[Creational.Singleton](creational/singleton/singleton.md)** +- **[Creational.Singleton](creational/singleton/singleton.md)** Fluent, thread-safe singleton with lazy/eager modes and init hooks. ## Structural -* **[Structural.Adapter.Adapter](structural/adapter/fluent-adapter.md)** +- **[Structural.Adapter.Adapter](structural/adapter/fluent-adapter.md)** Fluent in-place mapping with ordered validation for DTO projection. -* **[Structural.Bridge.Bridge](structural/bridge/bridge.md)** +- **[Structural.Bridge.Bridge](structural/bridge/bridge.md)** Abstraction/implementation split with fluent pre/post hooks and validations. -* **[Structural.Composite.Composite](structural/composite/composite.md)** +- **[Structural.Composite.Composite](structural/composite/composite.md)** Uniform treatment of leaves and compositions via seed+combine folding. +- **[Structural.Decorator.Decorator](structural/decorator/decorator.md)** + Fluent wrapping with before/after/around hooks and composition. + +- **[Structural.Facade.Facade](structural/facade/facade.md)** + Unified subsystem interface with named operations. + +- **[Structural.Flyweight](structural/flyweight/index.md)** + Identity sharing for high-volume immutable instances. + +- **[Structural.Proxy](structural/proxy/index.md)** + Virtual/protection/logging/caching/remote proxies. + --- ## Where to see them in action -* **Auth & Logging Chain** — request-ID logging + strict auth short-circuit using `ActionChain`. -* **Strategy-Based Coercion** — `TryStrategy` turns mixed inputs into typed values. -* **Mediated / Config-Driven Transaction Pipelines** — chains + strategies for totals, rounding, tender routing. -* **Minimal Web Request Router** — `BranchBuilder` for middleware and routes. +- **Auth & Logging Chain** — request-ID logging + strict auth short-circuit using `ActionChain`. +- **Strategy-Based Coercion** — `TryStrategy` turns mixed inputs into typed values. +- **Mediated / Config-Driven Transaction Pipelines** — chains + strategies for totals, rounding, tender routing. +- **Minimal Web Request Router** — `BranchBuilder` for middleware and routes. +- **State Machine** — order lifecycle with entry/exit hooks and default behaviors. > Tip: every pattern page has a tiny example; the demos show realistic combinations with TinyBDD tests you can read like specs. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index a866d8e..0532cd6 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -39,6 +39,8 @@ href: behavioral/observer/observer.md - name: AsyncObserver href: behavioral/observer/asyncobserver.md + - name: State + href: behavioral/state/state.md - name: Creational items: - name: Builder diff --git a/src/PatternKit.Core/Behavioral/State/AsyncStateMachine.cs b/src/PatternKit.Core/Behavioral/State/AsyncStateMachine.cs new file mode 100644 index 0000000..dd6993a --- /dev/null +++ b/src/PatternKit.Core/Behavioral/State/AsyncStateMachine.cs @@ -0,0 +1,233 @@ +using System.Collections.ObjectModel; +using System.Runtime.CompilerServices; + +namespace PatternKit.Behavioral.State; + +/// +/// An asynchronous, fluent state machine. Immutable after Build(); safe to share across threads. +/// +/// State identifier type (enum, string, record, etc.). +/// Event/trigger type driving transitions. +public sealed class AsyncStateMachine + where TState : notnull +{ + /// Asynchronous predicate determining if a transition applies. + public delegate ValueTask Predicate(TEvent @event, CancellationToken ct); + /// Asynchronous effect to run during a transition. + public delegate ValueTask Effect(TEvent @event, CancellationToken ct); + /// Asynchronous hook invoked on state entry/exit. + public delegate ValueTask StateHook(TEvent @event, CancellationToken ct); + + private readonly IEqualityComparer _cmp; + private readonly ReadOnlyDictionary _configs; + + private AsyncStateMachine(IEqualityComparer cmp, ReadOnlyDictionary configs) + => (_cmp, _configs) = (cmp, configs); + + /// + /// Tries to process an event in the given state. + /// Returns (handled, newState). If no rule handled the event, handled is false and newState == input state. + /// + public async ValueTask<(bool handled, TState state)> TryTransitionAsync(TState state, TEvent @event, CancellationToken ct = default) + { + if (!_configs.TryGetValue(state, out var cfg)) + return (false, state); + + var current = state; + var preds = cfg.Predicates; + for (int i = 0; i < preds.Length; i++) + { + if (await preds[i](@event, ct).ConfigureAwait(false)) + { + var tr = cfg.Edges[i]; + if (tr.HasNext && !_cmp.Equals(current, tr.Next)) + { + var exit = cfg.OnExit; + for (int j = 0; j < exit.Length; j++) + await exit[j](@event, ct).ConfigureAwait(false); + + if (tr.Effect is not null) + await tr.Effect(@event, ct).ConfigureAwait(false); + + var next = tr.Next; + current = next; + + if (_configs.TryGetValue(next, out var nextCfg)) + { + var enter = nextCfg.OnEnter; + for (int j = 0; j < enter.Length; j++) + await enter[j](@event, ct).ConfigureAwait(false); + } + } + else + { + if (tr.Effect is not null) + await tr.Effect(@event, ct).ConfigureAwait(false); + } + return (true, current); + } + } + + if (cfg.HasDefault) + { + var tr = cfg.Default; + if (tr.HasNext && !_cmp.Equals(current, tr.Next)) + { + var exit = cfg.OnExit; + for (int j = 0; j < exit.Length; j++) + await exit[j](@event, ct).ConfigureAwait(false); + if (tr.Effect is not null) + await tr.Effect(@event, ct).ConfigureAwait(false); + var next = tr.Next; + current = next; + if (_configs.TryGetValue(next, out var nextCfg)) + { + var enter = nextCfg.OnEnter; + for (int j = 0; j < enter.Length; j++) + await enter[j](@event, ct).ConfigureAwait(false); + } + } + else + { + if (tr.Effect is not null) + await tr.Effect(@event, ct).ConfigureAwait(false); + } + return (true, current); + } + + return (false, current); + } + + /// Processes an event or throws if unhandled. Returns the resulting state. + public async ValueTask TransitionAsync(TState state, TEvent @event, CancellationToken ct = default) + { + var (handled, next) = await TryTransitionAsync(state, @event, ct).ConfigureAwait(false); + if (!handled) + throw new InvalidOperationException($"Unhandled event '{@event?.ToString() ?? ""}' in state '{state?.ToString() ?? ""}'."); + return next; + } + + internal readonly struct Edge + { + public readonly bool HasNext; + public readonly TState Next; + public readonly Effect? Effect; + public Edge(bool hasNext, TState next, Effect? effect) + { HasNext = hasNext; Next = next; Effect = effect; } + public static Edge Stay(Effect? effect) => new(false, default!, effect); + public static Edge To(TState next, Effect? effect) => new(true, next, effect); + } + + private sealed class Config + { + public readonly Predicate[] Predicates; + public readonly Edge[] Edges; + public readonly StateHook[] OnEnter; + public readonly StateHook[] OnExit; + public readonly bool HasDefault; + public readonly Edge Default; + public Config(Predicate[] p, Edge[] e, StateHook[] onEnter, StateHook[] onExit, bool hasDef, Edge def) + => (Predicates, Edges, OnEnter, OnExit, HasDefault, Default) = (p, e, onEnter, onExit, hasDef, def); + } + + public sealed class Builder + { + private readonly Dictionary _states = new(); + private IEqualityComparer _cmp = EqualityComparer.Default; + + public Builder Comparer(IEqualityComparer comparer) + { _cmp = comparer ?? EqualityComparer.Default; return this; } + + public StateBuilder State(TState state) + { + if (!_states.TryGetValue(state, out var b)) + _states[state] = b = new StateBuilder(state, this); + return b; + } + + public Builder InState(TState state, Func configure) + { var b = State(state); configure(b); return this; } + + public AsyncStateMachine Build() + { + var dict = new Dictionary(_states.Count, _cmp); + foreach (var kv in _states) + { + var key = kv.Key; var sb = kv.Value; var t = sb.BuildTransitions(); + dict[key] = new Config(t.preds, t.edges, sb._onEnter.ToArray(), sb._onExit.ToArray(), t.hasDefault, t.def); + } + return new AsyncStateMachine(_cmp, new ReadOnlyDictionary(dict)); + } + + public sealed class StateBuilder + { + private readonly Builder _owner; + internal readonly TState _state; + private readonly List _preds = new(4); + private readonly List _edges = new(4); + internal readonly List _onEnter = new(2); + internal readonly List _onExit = new(2); + private bool _hasDefault; + private Edge _default; + + internal StateBuilder(TState state, Builder owner) => (_state, _owner) = (state, owner); + + public WhenBuilder When(Predicate pred) => new(this, pred); + public ThenBuilder Otherwise() => new(this, static (_, _) => new ValueTask(true)); + public StateBuilder OnEnter(StateHook hook) { if (hook is not null) _onEnter.Add(hook); return this; } + public StateBuilder OnExit(StateHook hook) { if (hook is not null) _onExit.Add(hook); return this; } + public Builder End() => _owner; + + internal (Predicate[] preds, Edge[] edges, bool hasDefault, Edge def) BuildTransitions() + => (_preds.ToArray(), _edges.ToArray(), _hasDefault, _default); + + public readonly struct WhenBuilder + { + private readonly StateBuilder _owner; + private readonly Predicate _pred; + internal WhenBuilder(StateBuilder owner, Predicate pred) => (_owner, _pred) = (owner, pred); + public ThenBuilder Permit(TState next) => new(_owner, _pred) { _hasNext = true, _next = next }; + public ThenBuilder Stay() => new(_owner, _pred) { _hasNext = false }; + } + + public struct ThenBuilder + { + private readonly StateBuilder _owner; + private readonly Predicate _pred; + internal bool _hasNext; + internal TState _next = default!; + internal ThenBuilder(StateBuilder owner, Predicate pred) => (_owner, _pred) = (owner, pred); + public ThenBuilder Permit(TState next) { _hasNext = true; _next = next; return this; } + public ThenBuilder Stay() { _hasNext = false; return this; } + + public StateBuilder Do(Effect effect) + { + var e = _hasNext ? Edge.To(_next, effect) : Edge.Stay(effect); + _owner._preds.Add(_pred); + _owner._edges.Add(e); + return _owner; + } + + public StateBuilder End() + { + var e = _hasNext ? Edge.To(_next, null) : Edge.Stay(null); + _owner._preds.Add(_pred); + _owner._edges.Add(e); + return _owner; + } + + public StateBuilder AsDefault() + { + var e = _hasNext ? Edge.To(_next, null) : Edge.Stay(null); + _owner._hasDefault = true; + _owner._default = e; + return _owner; + } + } + } + } + + /// Create a new builder. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Builder Create() => new(); +} diff --git a/src/PatternKit.Core/Behavioral/State/StateMachine.cs b/src/PatternKit.Core/Behavioral/State/StateMachine.cs new file mode 100644 index 0000000..4ee690a --- /dev/null +++ b/src/PatternKit.Core/Behavioral/State/StateMachine.cs @@ -0,0 +1,279 @@ +using System.Collections.ObjectModel; +using PatternKit.Common; + +namespace PatternKit.Behavioral.State; + +/// +/// A generic, allocation-light, fluent state machine. Immutable after Build(); thread-safe to share. +/// +/// State identifier type (enum, string, record, etc.). +/// Event/trigger type driving transitions. +/// +/// Design goals: +/// - Immutable compiled machine; zero allocations on hot path (array iteration). +/// - Predictable: first matching transition in registration order wins. +/// - Extensible: per-state entry/exit hooks, stay/ignore transitions, optional default per-state. +/// - Thread-safe: machine is immutable; callers pass the current state by ref. +/// +public sealed class StateMachine + where TState : notnull +{ + public delegate bool Predicate(in TEvent @event); + public delegate void Effect(in TEvent @event); + public delegate void StateHook(in TEvent @event); + + private readonly IEqualityComparer _cmp; + + private readonly ReadOnlyDictionary _configs; + + private StateMachine(IEqualityComparer cmp, ReadOnlyDictionary configs) + => (_cmp, _configs) = (cmp, configs); + + /// + /// Try to process against the current . Returns true if handled. + /// + /// The current state value. Will be updated when a transition occurs. + /// The incoming event. + public bool TryTransition(ref TState state, in TEvent @event) + { + if (!_configs.TryGetValue(state, out var cfg)) + return false; + + var preds = cfg.Predicates; + for (int i = 0; i < preds.Length; i++) + { + if (preds[i](in @event)) + { + var tr = cfg.Edges[i]; + + // Exit/Effect/Enter order per classic UML + if (tr.HasNext && !_cmp.Equals(state, tr.Next)) + { + // exit hooks of current + var exit = cfg.OnExit; + for (int j = 0; j < exit.Length; j++) exit[j](in @event); + + // transition effect + tr.Effect?.Invoke(in @event); + + var next = tr.Next; + state = next; + + if (_configs.TryGetValue(next, out var nextCfg)) + { + var enter = nextCfg.OnEnter; + for (int j = 0; j < enter.Length; j++) enter[j](in @event); + } + } + else + { + // stay/internal transition: effect only + tr.Effect?.Invoke(in @event); + } + + return true; + } + } + + // Default (if any) + if (cfg.HasDefault) + { + var tr = cfg.Default; + if (tr.HasNext && !_cmp.Equals(state, tr.Next)) + { + var exit = cfg.OnExit; + for (int j = 0; j < exit.Length; j++) exit[j](in @event); + tr.Effect?.Invoke(in @event); + var next = tr.Next; + state = next; + if (_configs.TryGetValue(next, out var nextCfg)) + { + var enter = nextCfg.OnEnter; + for (int j = 0; j < enter.Length; j++) enter[j](in @event); + } + } + else + { + tr.Effect?.Invoke(in @event); + } + return true; + } + + return false; + } + + /// + /// Process an event or throw if unhandled in the current state. + /// + public void Transition(ref TState state, in TEvent @event) + { + if (!TryTransition(ref state, in @event)) + throw new InvalidOperationException($"Unhandled event '{@event?.ToString() ?? ""}' in state '{state?.ToString() ?? ""}'."); + } + + internal readonly struct Edge + { + public readonly bool HasNext; + public readonly TState Next; + public readonly Effect? Effect; + public Edge(bool hasNext, TState next, Effect? effect) + { + HasNext = hasNext; + Next = next; + Effect = effect; + } + public static Edge Stay(Effect? effect) => new(false, default!, effect); + public static Edge To(TState next, Effect? effect) => new(true, next, effect); + } + + private sealed class Config + { + public readonly Predicate[] Predicates; + public readonly Edge[] Edges; + public readonly StateHook[] OnEnter; + public readonly StateHook[] OnExit; + public readonly bool HasDefault; + public readonly Edge Default; + public Config(Predicate[] p, Edge[] t, StateHook[] onEnter, StateHook[] onExit, bool hasDef, Edge def) + => (Predicates, Edges, OnEnter, OnExit, HasDefault, Default) = (p, t, onEnter, onExit, hasDef, def); + } + + public sealed class Builder + { + private readonly Dictionary _states = new(); + private IEqualityComparer _cmp = EqualityComparer.Default; + + /// Provide a custom equality comparer for TState (e.g., case-insensitive string states). + public Builder Comparer(IEqualityComparer comparer) + { + _cmp = comparer ?? EqualityComparer.Default; + return this; + } + + /// Create or get a builder for a specific state. + public StateBuilder State(TState state) + { + if (!_states.TryGetValue(state, out var b)) + _states[state] = b = new StateBuilder(state, this); + return b; + } + + /// Convenience to configure a state in-line. + public Builder InState(TState state, Func configure) + { + var b = State(state); + configure(b); + return this; + } + + public StateMachine Build() + { + var cfg = new Dictionary(_states.Count, _cmp); + foreach (var kvp in _states) + { + var key = kvp.Key; + var sb = kvp.Value; + var t = sb.BuildTransitions(); + cfg[key] = new Config( + t.preds, + t.edges, + sb._onEnter.ToArray(), + sb._onExit.ToArray(), + t.hasDefault, + t.def); + } + return new StateMachine(_cmp, new ReadOnlyDictionary(cfg)); + } + + // ----- Nested state builder ----- + public sealed class StateBuilder + { + internal readonly TState _state; + private readonly Builder _owner; + private readonly List _predicates = new(4); + private readonly List _edges = new(4); + internal readonly List _onEnter = new(2); + internal readonly List _onExit = new(2); + private bool _hasDefault; + private Edge _default; + + internal StateBuilder(TState state, Builder owner) => (_state, _owner) = (state, owner); + + public WhenBuilder When(Predicate predicate) => new(this, predicate); + + /// Default transition used when no predicate matches. + public ThenBuilder Otherwise() => new(this, static (in TEvent _) => true); + + /// Register an entry hook invoked after entering this state. + public StateBuilder OnEnter(StateHook hook) + { if (hook is not null) _onEnter.Add(hook); return this; } + + /// Register an exit hook invoked before leaving this state. + public StateBuilder OnExit(StateHook hook) + { if (hook is not null) _onExit.Add(hook); return this; } + + /// Fluent return to the machine builder. + public Builder End() => _owner; + + internal (Predicate[] preds, Edge[] edges, bool hasDefault, Edge def) BuildTransitions() + { + var preds = _predicates.ToArray(); + var edges = _edges.ToArray(); + return (preds, edges, _hasDefault, _default); + } + + public readonly struct WhenBuilder + { + private readonly StateBuilder _owner; + private readonly Predicate _pred; + internal WhenBuilder(StateBuilder owner, Predicate pred) => (_owner, _pred) = (owner, pred); + public ThenBuilder Permit(TState next) => new(_owner, _pred) { _next = next, _hasNext = true }; + public ThenBuilder Stay() => new(_owner, _pred) { _hasNext = false }; + } + + public struct ThenBuilder + { + private readonly StateBuilder _owner; + private readonly Predicate _pred; + internal bool _hasNext; + internal TState _next = default!; + internal ThenBuilder(StateBuilder owner, Predicate pred) => (_owner, _pred) = (owner, pred); + + /// Specify the next state for this rule (used with Otherwise as well). + public ThenBuilder Permit(TState next) { _hasNext = true; _next = next; return this; } + /// Specify this rule as a stay/internal transition. + public ThenBuilder Stay() { _hasNext = false; return this; } + + /// Attach a side-effect to run during the transition (between exit and entry). + public StateBuilder Do(Effect effect) + { + var tr = _hasNext ? Edge.To(_next, effect) : Edge.Stay(effect); + _owner._predicates.Add(_pred); + _owner._edges.Add(tr); + return _owner; + } + + /// No side-effect. + public StateBuilder End() + { + var tr = _hasNext ? Edge.To(_next, null) : Edge.Stay(null); + _owner._predicates.Add(_pred); + _owner._edges.Add(tr); + return _owner; + } + + /// Set as default instead of a conditional rule. + public StateBuilder AsDefault() + { + var tr = _hasNext ? Edge.To(_next, null) : Edge.Stay(null); + _owner._hasDefault = true; + _owner._default = tr; + return _owner; + } + } + } + } + + /// Create a new builder. + public static Builder Create() => new(); +} diff --git a/src/PatternKit.Examples/AsyncStateDemo/AsyncStateDemo.cs b/src/PatternKit.Examples/AsyncStateDemo/AsyncStateDemo.cs new file mode 100644 index 0000000..a098817 --- /dev/null +++ b/src/PatternKit.Examples/AsyncStateDemo/AsyncStateDemo.cs @@ -0,0 +1,47 @@ +using PatternKit.Behavioral.State; + +namespace PatternKit.Examples.AsyncStateDemo; + +public static class ConnectionStateDemo +{ + public enum Mode { Disconnected, Connecting, Connected, Error } + public readonly record struct NetEvent(string Kind); + + public static async ValueTask<(Mode Final, List Log)> RunAsync(params string[] events) + { + 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"); }) + ) + .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"); }) + ) + .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"); }) + ) + .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"); }) + ) + .Build(); + + var state = Mode.Disconnected; + foreach (var k in events) + { + var e = new NetEvent(k); + var (_, next) = await m.TryTransitionAsync(state, e); + state = next; + } + + return (state, log); + } +} + diff --git a/src/PatternKit.Examples/StateDemo/StateDemo.cs b/src/PatternKit.Examples/StateDemo/StateDemo.cs new file mode 100644 index 0000000..a6af4cd --- /dev/null +++ b/src/PatternKit.Examples/StateDemo/StateDemo.cs @@ -0,0 +1,54 @@ +using PatternKit.Behavioral.State; + +namespace PatternKit.Examples.StateDemo; + +public static class OrderStateDemo +{ + public enum OrderState { New, Paid, Shipped, Delivered, Cancelled, Refunded } + public readonly record struct OrderEvent(string Kind); + + 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")) + ) + .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")) + ) + .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")) + ) + .InState(OrderState.Delivered, s => s + .OnEnter((in OrderEvent _) => log.Add("notify:delivered")) + .Otherwise().Stay().Do((in OrderEvent _) => 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")) + ) + .InState(OrderState.Refunded, s => s + .OnEnter((in OrderEvent _) => log.Add("notify:refunded")) + .Otherwise().Stay().Do((in OrderEvent _) => log.Add("ignore")) + ) + .Build(); + + var state = OrderState.New; + foreach (var k in events) + { + var e = new OrderEvent(k); + machine.TryTransition(ref state, in e); + } + return (state, log); + } +} + diff --git a/test/PatternKit.Examples.Tests/AsyncStateDemo/AsyncStateDemoTests.cs b/test/PatternKit.Examples.Tests/AsyncStateDemo/AsyncStateDemoTests.cs new file mode 100644 index 0000000..5994de3 --- /dev/null +++ b/test/PatternKit.Examples.Tests/AsyncStateDemo/AsyncStateDemoTests.cs @@ -0,0 +1,49 @@ +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; + +public sealed class AsyncStateDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Connect flow: connect -> ok => enters Connected with ordered hooks/effects")] + [Fact] + public async Task Connect_Flow() + { + await Given("async connection demo", () => default(object?)) + .When<(ConnectionStateDemo.Mode Final, System.Collections.Generic.List Log)>( + "run connect, ok", + (Func)>>)(_ => ConnectionStateDemo.RunAsync("connect", "ok").AsTask()) + ) + .Then("final Connected and logs show exit/eff/enter handover", r => + r.Final == ConnectionStateDemo.Mode.Connected && + string.Join(',', r.Log.ToArray()) == string.Join(',', new[]{ + "exit:Disconnected","effect:dial","enter:Connecting", + "exit:Connecting","effect:handshake","enter:Connected" + })) + .AssertPassed(); + } + + [Scenario("Connected default stay only runs effect:noop")] + [Fact] + public async Task Default_Stay_NoOp() + { + await Given<(ConnectionStateDemo.Mode Final, System.Collections.Generic.List Log)>( + "connected", + (Func)>>)(() => ConnectionStateDemo.RunAsync("connect","ok").AsTask()) + ) + .When<(ConnectionStateDemo.Mode Final, System.Collections.Generic.List Log)>( + "unknown event in Connected", + (Func<(ConnectionStateDemo.Mode, System.Collections.Generic.List), Task<(ConnectionStateDemo.Mode, System.Collections.Generic.List)>>)( + _ => ConnectionStateDemo.RunAsync("connect","ok","ignore").AsTask() + ) + ) + .Then("still Connected and last step noop", r => r.Final == ConnectionStateDemo.Mode.Connected && r.Log.Last() == "effect:noop") + .AssertPassed(); + } +} diff --git a/test/PatternKit.Examples.Tests/StateDemo/StateDemoTests.cs b/test/PatternKit.Examples.Tests/StateDemo/StateDemoTests.cs new file mode 100644 index 0000000..f39e698 --- /dev/null +++ b/test/PatternKit.Examples.Tests/StateDemo/StateDemoTests.cs @@ -0,0 +1,54 @@ +using System.Linq; +using PatternKit.Examples.StateDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.StateDemo; + +public sealed class StateDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Happy path: pay -> ship -> deliver")] + [Fact] + public async Task Happy_Path() + { + await Given("order lifecycle demo", () => (Final: OrderStateDemo.OrderState.New, Log: new List())) + .When("run events pay, ship, deliver", _ => OrderStateDemo.Run("pay", "ship", "deliver")) + .Then("final state Delivered and audited sequence recorded", r => + r.Final == OrderStateDemo.OrderState.Delivered && + string.Join(",", r.Log.ToArray()) == string.Join(",", + new[]{ + "audit:new->","charge","notify:paid", + "audit:paid->","ship","notify:shipped", + "audit:shipped->","deliver","notify:delivered" + })) + .AssertPassed(); + } + + [Scenario("Cancellation and refund path")] + [Fact] + public async Task Cancel_Refund() + { + await Given("order", () => default(object?)) + .When("new -> cancel -> refund", _ => OrderStateDemo.Run("cancel", "refund")) + .Then("final state Refunded and logs include notifications and refund", r => + r.Final == OrderStateDemo.OrderState.Refunded && + string.Join(",", r.Log.ToArray()) == string.Join(",", + new[]{ + "audit:new->","cancel","notify:cancelled", + "refund","notify:refunded" + })) + .AssertPassed(); + } + + [Scenario("Delivered ignores further events via default stay")] + [Fact] + public async Task Delivered_Ignores() + { + await Given("order delivered", () => OrderStateDemo.Run("pay","ship","deliver")) + .When("send unknown event after delivered", _ => OrderStateDemo.Run("pay","ship","deliver","x")) + .Then("still Delivered and last step ignored", r => r.Final == OrderStateDemo.OrderState.Delivered && r.Log.Last() == "ignore") + .AssertPassed(); + } +} diff --git a/test/PatternKit.Tests/Behavioral/State/AsyncStateMachineTests.cs b/test/PatternKit.Tests/Behavioral/State/AsyncStateMachineTests.cs new file mode 100644 index 0000000..658af44 --- /dev/null +++ b/test/PatternKit.Tests/Behavioral/State/AsyncStateMachineTests.cs @@ -0,0 +1,96 @@ +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; + +public sealed class AsyncStateMachineTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + private enum S { Idle, Active, Alarm } + private readonly record struct Ev(string Kind); + + private sealed record Ctx(AsyncStateMachine M, List Log, S State, bool LastOk = false, Exception? Ex = null); + + 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"); }) + ) + .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"); }) + ) + .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"); }) + ) + .Build(); + return new Ctx(m, log, S.Idle); + } + + private static async ValueTask Fire(Ctx c, string k) + { + var (ok, s) = await c.M.TryTransitionAsync(c.State, new Ev(k)); + return c with { State = s, LastOk = ok }; + } + + private static async ValueTask FireThrow(Ctx c, string k) + { + try + { + var s = await c.M.TransitionAsync(c.State, new Ev(k)); + return c with { State = s, Ex = null }; + } + catch (Exception ex) + { + return c with { Ex = ex }; + } + } + + [Scenario("Exit/Effect/Enter order on async state change; stay executes effect only")] + [Fact] + public async Task Order_And_Stay_Async() + { + await Given("async machine", Build) + .When("go to Active", c => Fire(c, "go")) + .Then("moved to Active and order ok", c => c.LastOk && c.State == S.Active && string.Join(',', c.Log.ToArray()) == "exit:Idle,effect:go,enter:Active") + .When("stay with ping", c => { c.Log.Clear(); return Fire(c with { State = S.Active }, "ping"); }) + .Then("only effect ran", c => c.LastOk && c.State == S.Active && string.Join(',', c.Log.ToArray()) == "effect:ping") + .AssertPassed(); + } + + [Scenario("Default fires async when nothing matches")] + [Fact] + public async Task Default_Fires_Async() + { + await Given("in Alarm state", Build) + .When("to Alarm", c => Fire(c, "panic")) + .When("unknown event", c => { c.Log.Clear(); return Fire(c, "unknown"); }) + .Then("handled by default", c => c.LastOk && c.State == S.Alarm && c.Log.SequenceEqual(["effect:default"])) + .AssertPassed(); + } + + [Scenario("Unhandled path throws in async TransitionAsync")] + [Fact] + public async Task Unhandled_Async() + { + await Given("async machine", Build) + .When("unknown via TryTransitionAsync", c => Fire(c, "nope")) + .Then("returns false", c => !c.LastOk && c.State == S.Idle) + .When("unknown via TransitionAsync", c => FireThrow(c, "nope")) + .Then("throws InvalidOperationException", c => c.Ex is InvalidOperationException) + .AssertPassed(); + } +} diff --git a/test/PatternKit.Tests/Behavioral/State/StateMachineTests.cs b/test/PatternKit.Tests/Behavioral/State/StateMachineTests.cs new file mode 100644 index 0000000..874ff57 --- /dev/null +++ b/test/PatternKit.Tests/Behavioral/State/StateMachineTests.cs @@ -0,0 +1,125 @@ +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; + +public sealed class StateMachineTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + private enum S { Idle, Active, Alarm } + private readonly record struct Ev(string Kind); + + private static bool Is(string k, in Ev e) => e.Kind == k; + + private sealed record Ctx(StateMachine M, List Log, S State, bool LastOk = false, Exception? Ex = null); + + 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")) + ) + .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")) + ) + .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")) + ) + .Build(); + return new Ctx(m, log, S.Idle); + } + + private static Ctx Fire(Ctx c, string kind) + { + var s = c.State; + var e = new Ev(kind); + var ok = c.M.TryTransition(ref s, in e); + return c with { State = s, LastOk = ok }; + } + + private static Ctx FireThrow(Ctx c, string kind) + { + try + { + var s = c.State; + var e = new Ev(kind); + c.M.Transition(ref s, in e); + return c with { State = s, Ex = null }; + } + catch (Exception ex) + { + return c with { Ex = ex }; + } + } + + [Scenario("Exit/Effect/Enter order on state change; stay executes effect only")] + [Fact] + public async Task Order_And_Stay() + { + await Given("a simple 3-state machine context", BuildCtx) + .When("go from Idle -> Active", c => Fire(c, "go")) + .Then("moved to Active and exit/effect/enter order recorded", c => + c.LastOk && c.State == S.Active && string.Join(",", c.Log.ToArray()) == "exit:Idle,effect:go,enter:Active") + .When("ping stays in Active with effect only", c => { c.Log.Clear(); return (Fire(c with { State = S.Active }, "ping")); }) + .Then("remains Active and only effect logged", c => c.LastOk && c.State == S.Active && string.Join(",", c.Log.ToArray()) == "effect:ping") + .AssertPassed(); + } + + [Scenario("Default transition fires when nothing matches")] + [Fact] + public async Task Default_Fires() + { + await Given("in Alarm with default stay", BuildCtx) + .When("go to Alarm first", c => Fire(c, "panic")) + .When("unknown event handled by default", c => { c.Log.Clear(); return Fire(c, "unknown"); }) + .Then("handled, remained Alarm, effect ran", c => c.LastOk && c.State == S.Alarm && string.Join(",", c.Log.ToArray()) == "effect:default") + .AssertPassed(); + } + + [Scenario("Unhandled event yields false for TryTransition and throws for Transition")] + [Fact] + public async Task Unhandled_Behavior() + { + await Given("machine ctx", BuildCtx) + .When("unknown via TryTransition", c => Fire(c, "nope")) + .Then("returns false and state unchanged", c => !c.LastOk && c.State == S.Idle) + .When("unknown via Transition throws", c => FireThrow(c, "nope")) + .Then("throws InvalidOperationException", c => c.Ex is InvalidOperationException) + .AssertPassed(); + } + + [Scenario("Registration order: first matching transition wins")] + [Fact] + 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")) + ) + .Build(); + + await Given("ctx", () => new Ctx(m, log, S.Idle)) + .When("'x' should match first rule", c => + { + var s = c.State; var e = new Ev("x"); var ok = c.M.TryTransition(ref s, in e); + return c with { State = s, LastOk = ok }; + }) + .Then("consumed by first rule; no state change and first logged", c => c.LastOk && c.State == S.Idle && log.First() == "first") + .AssertPassed(); + } +}