From 583d82c28ea86b8d84256c9798b35da6e03d9aa6 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Wed, 20 May 2026 15:25:58 -0500 Subject: [PATCH] feat: add specification pattern --- docs/examples/loan-approval-specifications.md | 36 +++ docs/examples/toc.yml | 3 + docs/generators/index.md | 1 + docs/generators/specification.md | 52 ++++ docs/generators/toc.yml | 3 + docs/guides/pattern-coverage.md | 1 + docs/patterns/application/specification.md | 52 ++++ .../Specification/Specification.cs | 122 ++++++++++ ...rnKitExampleServiceCollectionExtensions.cs | 13 + .../PatternKitExampleCatalog.cs | 8 + .../PatternKitPatternCatalog.cs | 14 +- .../LoanApprovalSpecificationDemo.cs | 109 +++++++++ .../Specification/SpecificationAttributes.cs | 16 ++ .../AnalyzerReleases.Unshipped.md | 4 + .../Specification/SpecificationGenerator.cs | 230 ++++++++++++++++++ ...tternKitExampleDependencyInjectionTests.cs | 4 +- .../PatternKitPatternCatalogTests.cs | 5 +- .../LoanApprovalSpecificationDemoTests.cs | 55 +++++ .../AbstractionsAttributeCoverageTests.cs | 20 ++ .../SpecificationGeneratorTests.cs | 183 ++++++++++++++ .../Specification/SpecificationTests.cs | 66 +++++ 21 files changed, 993 insertions(+), 4 deletions(-) create mode 100644 docs/examples/loan-approval-specifications.md create mode 100644 docs/generators/specification.md create mode 100644 docs/patterns/application/specification.md create mode 100644 src/PatternKit.Core/Application/Specification/Specification.cs create mode 100644 src/PatternKit.Examples/SpecificationDemo/LoanApprovalSpecificationDemo.cs create mode 100644 src/PatternKit.Generators.Abstractions/Specification/SpecificationAttributes.cs create mode 100644 src/PatternKit.Generators/Specification/SpecificationGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/SpecificationDemo/LoanApprovalSpecificationDemoTests.cs create mode 100644 test/PatternKit.Generators.Tests/SpecificationGeneratorTests.cs create mode 100644 test/PatternKit.Tests/Application/Specification/SpecificationTests.cs diff --git a/docs/examples/loan-approval-specifications.md b/docs/examples/loan-approval-specifications.md new file mode 100644 index 00000000..2b3f608f --- /dev/null +++ b/docs/examples/loan-approval-specifications.md @@ -0,0 +1,36 @@ +# Loan Approval Specifications + +This example demonstrates a production-style Specification pattern for loan approvals. It includes a fluent registry, a source-generated registry, TinyBDD tests, and an `IServiceCollection` extension. + +## Import + +```csharp +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.SpecificationDemo; + +var services = new ServiceCollection(); +services.AddLoanApprovalSpecifications(); + +using var provider = services.BuildServiceProvider(validateScopes: true); +var service = provider.GetRequiredService(); +``` + +## Evaluate + +```csharp +var application = LoanApprovalSpecificationDemo.CreatePrimeApplication(); +var decision = service.Evaluate(application); +``` + +The service uses a generated `SpecificationRegistry` so importing applications can replace, decorate, or inspect the named rule registry through standard .NET IoC tooling. + +## Rules + +- `verified-identity` +- `clear-fraud` +- `prime-credit` +- `stable-income` +- `affordable` +- `approval-ready` + +The fluent and generated paths are tested against the same loan applications so consumers can choose either style without changing application behavior. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 25ce9efa..b7416457 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -67,6 +67,9 @@ - name: CQRS Dispatcher href: cqrs-dispatcher.md +- name: Loan Approval Specifications + href: loan-approval-specifications.md + - name: Generated Mailbox href: generated-mailbox.md diff --git a/docs/generators/index.md b/docs/generators/index.md index 67887582..0a17aec3 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -58,6 +58,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Observer**](observer.md) | Event hubs and observer dispatch | `[ObserverHub]` | | [**State Machine**](state-machine.md) | Deterministic finite state machines | `[StateMachine]` | | [**Strategy**](strategy.md) | Predicate-based dispatch with fluent builder | `[GenerateStrategy]` | +| [**Specification**](specification.md) | Named business-rule registries | `[GenerateSpecificationRegistry]` | | [**Template Method**](template-method-generator.md) | Template method skeletons with hook points | `[Template]` | | [**Visitor**](visitor-generator.md) | Type-safe visitor implementations | `[GenerateVisitor]` | diff --git a/docs/generators/specification.md b/docs/generators/specification.md new file mode 100644 index 00000000..0271592c --- /dev/null +++ b/docs/generators/specification.md @@ -0,0 +1,52 @@ +# Specification Generator + +The Specification generator turns annotated static rule methods into a named `SpecificationRegistry` factory. + +## Usage + +```csharp +using PatternKit.Generators.Specification; + +[GenerateSpecificationRegistry(typeof(LoanApplication), FactoryMethodName = "Build")] +public static partial class LoanApprovalRules +{ + [SpecificationRule("verified-identity")] + private static bool VerifiedIdentity(LoanApplication application) + => application.HasVerifiedIdentity; +} +``` + +Generated output: + +```csharp +var registry = LoanApprovalRules.Build(); +var approved = registry.IsSatisfiedBy("verified-identity", application); +``` + +## Rule Shape + +Specification rules must be static methods with this shape: + +```csharp +static bool Rule(TCandidate candidate) +``` + +Rule names must be unique within the generated registry. + +## Diagnostics + +| ID | Meaning | +|---|---| +| `PKSPEC001` | Host type must be `partial`. | +| `PKSPEC002` | Host type has no `[SpecificationRule]` methods. | +| `PKSPEC003` | Rule method signature is invalid. | +| `PKSPEC004` | Rule name is duplicated. | + +## Dependency Injection + +```csharp +services.AddSingleton(_ => LoanApprovalRules.Build()); +services.AddSingleton(); +``` + +Use generated registries when teams want a stable, named rule surface that can be injected into existing ASP.NET Core or Generic Host applications. diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 7e35cba8..7e28eb69 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -64,6 +64,9 @@ - name: Singleton href: singleton.md +- name: Specification + href: specification.md + - name: State Machine href: state-machine.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index fecb81cc..f0b2735e 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -58,6 +58,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Enterprise Integration | Request-Reply | Messaging backplane facade example | Backplane topology generator | | Enterprise Integration | Publish-Subscribe | Messaging backplane facade example | Backplane topology generator | | Application Architecture | CQRS | Mediator/dispatcher command-query split | Dispatcher generator | +| Application Architecture | Specification | `Specification` and named registries | Specification generator | ## Research Baselines diff --git a/docs/patterns/application/specification.md b/docs/patterns/application/specification.md new file mode 100644 index 00000000..c2f257d6 --- /dev/null +++ b/docs/patterns/application/specification.md @@ -0,0 +1,52 @@ +# Specification + +Specification packages business rules as reusable predicates that can be composed, named, tested, and registered in dependency injection. + +Use it when a domain decision has several independently meaningful rules, such as approval checks, eligibility gates, routing policies, or validation criteria. + +## Fluent Path + +```csharp +using PatternKit.Application.Specification; + +var verified = Specification + .Where("verified-identity", application => application.HasVerifiedIdentity); + +var clearFraud = Specification + .Where("clear-fraud", application => !application.HasFraudHold); + +var approval = verified.And(clearFraud, "approval-ready"); + +var registry = SpecificationRegistry.Create() + .Add(verified.Name, verified) + .Add(clearFraud.Name, clearFraud) + .Add(approval.Name, approval) + .Build(); +``` + +`SpecificationRegistry` is the production integration point. It gives application services a stable named rule set without forcing them to know how each rule was composed. + +## Generated Path + +```csharp +using PatternKit.Generators.Specification; + +[GenerateSpecificationRegistry(typeof(LoanApplication))] +public static partial class LoanApprovalRules +{ + [SpecificationRule("prime-credit")] + private static bool PrimeCredit(LoanApplication application) + => application.CreditScore >= 700; +} +``` + +The generator emits a static factory returning `SpecificationRegistry`. Generated registries are useful when rule names and method signatures should be compile-time checked. + +## IoC Usage + +```csharp +services.AddSingleton(_ => LoanApprovalRules.Create()); +services.AddSingleton(); +``` + +The example in `docs/examples/loan-approval-specifications.md` shows a complete importable `IServiceCollection` integration. diff --git a/src/PatternKit.Core/Application/Specification/Specification.cs b/src/PatternKit.Core/Application/Specification/Specification.cs new file mode 100644 index 00000000..7f578df6 --- /dev/null +++ b/src/PatternKit.Core/Application/Specification/Specification.cs @@ -0,0 +1,122 @@ +namespace PatternKit.Application.Specification; + +/// +/// Represents a business rule that can decide whether a candidate satisfies a named condition. +/// +/// Candidate type evaluated by the rule. +public interface ISpecification +{ + /// Evaluates the candidate against the rule. + bool IsSatisfiedBy(T candidate); +} + +/// +/// Fluent specification implementation with composition helpers for domain rules. +/// +/// Candidate type evaluated by the rule. +public sealed class Specification : ISpecification +{ + private readonly Func _predicate; + + private Specification(string name, Func predicate) + { + Name = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Specification name is required.", nameof(name)) + : name; + _predicate = predicate ?? throw new ArgumentNullException(nameof(predicate)); + } + + /// Human-readable rule name used by registries and diagnostics. + public string Name { get; } + + /// Creates a specification from a predicate. + public static Specification Where(string name, Func predicate) => new(name, predicate); + + /// Creates a specification that accepts every candidate. + public static Specification All(string name = "all") => new(name, static _ => true); + + /// Creates a specification that rejects every candidate. + public static Specification None(string name = "none") => new(name, static _ => false); + + /// + public bool IsSatisfiedBy(T candidate) => _predicate(candidate); + + /// Returns this specification as a predicate delegate. + public Func ToPredicate() => _predicate; + + /// Composes this specification with another specification using logical AND. + public Specification And(ISpecification other, string? name = null) + { + if (other is null) + throw new ArgumentNullException(nameof(other)); + + return new Specification(name ?? $"{Name}.and", candidate => IsSatisfiedBy(candidate) && other.IsSatisfiedBy(candidate)); + } + + /// Composes this specification with another specification using logical OR. + public Specification Or(ISpecification other, string? name = null) + { + if (other is null) + throw new ArgumentNullException(nameof(other)); + + return new Specification(name ?? $"{Name}.or", candidate => IsSatisfiedBy(candidate) || other.IsSatisfiedBy(candidate)); + } + + /// Negates this specification. + public Specification Not(string? name = null) + => new(name ?? $"{Name}.not", candidate => !IsSatisfiedBy(candidate)); +} + +/// +/// Named collection of specifications for application services and IoC registrations. +/// +/// Candidate type evaluated by the registered rules. +public sealed class SpecificationRegistry +{ + private readonly IReadOnlyDictionary> _specifications; + + private SpecificationRegistry(IReadOnlyDictionary> specifications) + => _specifications = specifications; + + /// Registered specification names. + public IReadOnlyCollection Names => _specifications.Keys.ToArray(); + + /// Creates a fluent registry builder. + public static Builder Create() => new(); + + /// Gets a specification by name. + public ISpecification Get(string name) + { + if (!_specifications.TryGetValue(name, out var specification)) + throw new KeyNotFoundException($"Specification '{name}' is not registered."); + + return specification; + } + + /// Evaluates a named specification against a candidate. + public bool IsSatisfiedBy(string name, T candidate) => Get(name).IsSatisfiedBy(candidate); + + /// Builds a named specification registry. + public sealed class Builder + { + private readonly Dictionary> _specifications = new(StringComparer.Ordinal); + + /// Adds a specification to the registry. + public Builder Add(string name, ISpecification specification) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Specification name is required.", nameof(name)); + + _specifications[name] = specification ?? throw new ArgumentNullException(nameof(specification)); + return this; + } + + /// Adds a predicate-backed specification to the registry. + public Builder Add(string name, Func predicate) + => Add(name, Specification.Where(name, predicate)); + + /// Builds an immutable registry snapshot. + public SpecificationRegistry Build() + => new(new Dictionary>(_specifications, StringComparer.Ordinal)); + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index e1c5aec2..65258120 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using PatternKit.Application.Specification; using PatternKit.Behavioral.Chain; using PatternKit.Behavioral.Interpreter; using PatternKit.Behavioral.Strategy; @@ -26,6 +27,7 @@ using PatternKit.Examples.PrototypeDemo; using PatternKit.Examples.ProxyDemo; using PatternKit.Examples.Singleton; +using PatternKit.Examples.SpecificationDemo; using PatternKit.Examples.Strategies.Coercion; using PatternKit.Examples.Strategies.Composed; using PatternKit.Examples.TemplateDemo; @@ -101,6 +103,7 @@ public sealed record GeneratedReliabilityPipelineExample(ReliabilityExampleRunne public sealed record ResilientCheckoutMailboxesExample(Func Run); public sealed record MessagingBackplaneFacadeExample(Func> RunAsync); public sealed record GeneratedInterpreterRulesExample(Interpreter Pricing, Interpreter Eligibility); +public sealed record LoanApprovalSpecificationsExample(SpecificationRegistry Registry, LoanApprovalService Service); public sealed record PrototypeGameCharacterFactoryExample(Prototype Factory); public sealed record ProxyPatternDemonstrationsExample(Proxy RemoteProxy, Proxy<(string To, string Subject, string Body), bool> EmailProxy); public sealed record FlyweightGlyphCacheExample(Func> RenderSentence); @@ -147,6 +150,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddResilientCheckoutMailboxesExample() .AddMessagingBackplaneFacadeExample() .AddGeneratedInterpreterRulesExample() + .AddLoanApprovalSpecificationsExample() .AddPrototypeGameCharacterFactoryExample() .AddProxyPatternDemonstrationsExample() .AddFlyweightGlyphCacheExample() @@ -437,6 +441,15 @@ public static IServiceCollection AddGeneratedInterpreterRulesExample(this IServi return services.RegisterExample("Generated Interpreter Rules", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddLoanApprovalSpecificationsExample(this IServiceCollection services) + { + services.AddLoanApprovalSpecifications(); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService())); + return services.RegisterExample("Loan Approval Specifications", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); + } + public static IServiceCollection AddPrototypeGameCharacterFactoryExample(this IServiceCollection services) { services.AddSingleton(_ => PrototypeDemo.PrototypeDemo.CreateCharacterFactory()); diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 52785e57..6a0e13cb 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -264,6 +264,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.GenericHost, ["Mediator", "Dispatcher", "CQRS"], ["command/query separation", "source-generated dispatcher", "DI composition"]), + Descriptor( + "Loan Approval Specifications", + "src/PatternKit.Examples/SpecificationDemo/LoanApprovalSpecificationDemo.cs", + "test/PatternKit.Examples.Tests/SpecificationDemo/LoanApprovalSpecificationDemoTests.cs", + "docs/examples/loan-approval-specifications.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, + ["Specification"], + ["composable business rules", "source-generated registry", "DI composition"]), Descriptor( "Generated Mailbox", "src/PatternKit.Examples/Messaging/MailboxExample.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index e4d509ec..9232ec74 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -543,7 +543,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "docs/examples/cqrs-dispatcher.md", "src/PatternKit.Examples/Messaging/CqrsPatternExample.cs", "test/PatternKit.Examples.Tests/Messaging/CqrsPatternExampleTests.cs", - ["fluent mediator command/query separation", "generated dispatcher", "DI-importable CQRS example"]) + ["fluent mediator command/query separation", "generated dispatcher", "DI-importable CQRS example"]), + Pattern("Specification", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/specification.md", + "src/PatternKit.Core/Application/Specification/Specification.cs", + "test/PatternKit.Tests/Application/Specification/SpecificationTests.cs", + "docs/generators/specification.md", + "src/PatternKit.Generators/Specification/SpecificationGenerator.cs", + "test/PatternKit.Generators.Tests/SpecificationGeneratorTests.cs", + null, + "docs/examples/loan-approval-specifications.md", + "src/PatternKit.Examples/SpecificationDemo/LoanApprovalSpecificationDemo.cs", + "test/PatternKit.Examples.Tests/SpecificationDemo/LoanApprovalSpecificationDemoTests.cs", + ["fluent specification composition", "generated specification registry", "DI-importable loan approval example"]) ]; public IReadOnlyList Patterns => Items; diff --git a/src/PatternKit.Examples/SpecificationDemo/LoanApprovalSpecificationDemo.cs b/src/PatternKit.Examples/SpecificationDemo/LoanApprovalSpecificationDemo.cs new file mode 100644 index 00000000..629b4106 --- /dev/null +++ b/src/PatternKit.Examples/SpecificationDemo/LoanApprovalSpecificationDemo.cs @@ -0,0 +1,109 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.Specification; +using PatternKit.Generators.Specification; + +namespace PatternKit.Examples.SpecificationDemo; + +/// +/// Production-style loan approval rules implemented with fluent and source-generated specifications. +/// +public static class LoanApprovalSpecificationDemo +{ + public sealed record LoanApplication( + string ApplicationId, + decimal AnnualIncome, + decimal RequestedAmount, + int CreditScore, + int MonthsEmployed, + bool HasFraudHold, + bool HasVerifiedIdentity); + + public sealed record LoanDecision(string ApplicationId, bool Approved, IReadOnlyList FailedRules); + + public static SpecificationRegistry CreateFluentRegistry() + { + var verified = Specification.Where("verified-identity", static application => application.HasVerifiedIdentity); + var clearFraud = Specification.Where("clear-fraud", static application => !application.HasFraudHold); + var primeCredit = Specification.Where("prime-credit", static application => application.CreditScore >= 700); + var stableIncome = Specification.Where("stable-income", static application => application.MonthsEmployed >= 12); + var affordable = Specification.Where("affordable", static application => + application.AnnualIncome > 0m && application.RequestedAmount <= application.AnnualIncome * 0.35m); + var approval = verified.And(clearFraud).And(primeCredit).And(stableIncome).And(affordable, "approval-ready"); + + return SpecificationRegistry.Create() + .Add(verified.Name, verified) + .Add(clearFraud.Name, clearFraud) + .Add(primeCredit.Name, primeCredit) + .Add(stableIncome.Name, stableIncome) + .Add(affordable.Name, affordable) + .Add(approval.Name, approval) + .Build(); + } + + public static SpecificationRegistry CreateGeneratedRegistry() + => GeneratedLoanApprovalSpecifications.Create(); + + public static LoanDecision Evaluate(LoanApplication application, SpecificationRegistry registry) + { + var failed = registry.Names + .Where(name => name != "approval-ready" && !registry.IsSatisfiedBy(name, application)) + .OrderBy(static name => name) + .ToArray(); + + return new LoanDecision( + application.ApplicationId, + registry.IsSatisfiedBy("approval-ready", application), + failed); + } + + public static IServiceCollection AddLoanApprovalSpecifications(this IServiceCollection services) + { + services.AddSingleton(static _ => CreateGeneratedRegistry()); + services.AddSingleton(); + return services; + } + + public static LoanApplication CreatePrimeApplication() + => new("APP-100", 180_000m, 50_000m, 742, 36, false, true); + + public static LoanApplication CreateHeldApplication() + => new("APP-200", 180_000m, 50_000m, 742, 36, true, true); +} + +public sealed class LoanApprovalService(SpecificationRegistry registry) +{ + public LoanApprovalSpecificationDemo.LoanDecision Evaluate(LoanApprovalSpecificationDemo.LoanApplication application) + => LoanApprovalSpecificationDemo.Evaluate(application, registry); +} + +[GenerateSpecificationRegistry(typeof(LoanApprovalSpecificationDemo.LoanApplication))] +public static partial class GeneratedLoanApprovalSpecifications +{ + [SpecificationRule("affordable")] + private static bool Affordable(LoanApprovalSpecificationDemo.LoanApplication application) + => application.AnnualIncome > 0m && application.RequestedAmount <= application.AnnualIncome * 0.35m; + + [SpecificationRule("approval-ready")] + private static bool ApprovalReady(LoanApprovalSpecificationDemo.LoanApplication application) + => VerifiedIdentity(application) + && ClearFraud(application) + && PrimeCredit(application) + && StableIncome(application) + && Affordable(application); + + [SpecificationRule("clear-fraud")] + private static bool ClearFraud(LoanApprovalSpecificationDemo.LoanApplication application) + => !application.HasFraudHold; + + [SpecificationRule("prime-credit")] + private static bool PrimeCredit(LoanApprovalSpecificationDemo.LoanApplication application) + => application.CreditScore >= 700; + + [SpecificationRule("stable-income")] + private static bool StableIncome(LoanApprovalSpecificationDemo.LoanApplication application) + => application.MonthsEmployed >= 12; + + [SpecificationRule("verified-identity")] + private static bool VerifiedIdentity(LoanApprovalSpecificationDemo.LoanApplication application) + => application.HasVerifiedIdentity; +} diff --git a/src/PatternKit.Generators.Abstractions/Specification/SpecificationAttributes.cs b/src/PatternKit.Generators.Abstractions/Specification/SpecificationAttributes.cs new file mode 100644 index 00000000..8da81409 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Specification/SpecificationAttributes.cs @@ -0,0 +1,16 @@ +namespace PatternKit.Generators.Specification; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateSpecificationRegistryAttribute(Type candidateType) : Attribute +{ + public Type CandidateType { get; } = candidateType ?? throw new ArgumentNullException(nameof(candidateType)); + public string FactoryMethodName { get; set; } = "Create"; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class SpecificationRuleAttribute(string name) : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Specification name is required.", nameof(name)) + : name; +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 9257d7d9..73846591 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -197,6 +197,10 @@ PKINT001 | PatternKit.Generators.Interpreter | Error | Interpreter host must be PKINT002 | PatternKit.Generators.Interpreter | Error | Interpreter must declare at least one rule. PKINT003 | PatternKit.Generators.Interpreter | Error | Interpreter rule signature is invalid. PKINT004 | PatternKit.Generators.Interpreter | Error | Interpreter rule declaration is duplicated. +PKSPEC001 | PatternKit.Generators.Specification | Error | Specification registry host must be partial. +PKSPEC002 | PatternKit.Generators.Specification | Error | Specification registry must declare at least one rule. +PKSPEC003 | PatternKit.Generators.Specification | Error | Specification rule signature is invalid. +PKSPEC004 | PatternKit.Generators.Specification | Error | Specification rule declaration is duplicated. PKRL001 | PatternKit.Generators.Messaging | Error | Recipient list type must be partial. PKRL002 | PatternKit.Generators.Messaging | Error | Recipient list must declare at least one recipient. PKRL003 | PatternKit.Generators.Messaging | Error | Recipient handler or predicate signature is invalid. diff --git a/src/PatternKit.Generators/Specification/SpecificationGenerator.cs b/src/PatternKit.Generators/Specification/SpecificationGenerator.cs new file mode 100644 index 00000000..b84b514b --- /dev/null +++ b/src/PatternKit.Generators/Specification/SpecificationGenerator.cs @@ -0,0 +1,230 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.Specification; + +[Generator] +public sealed class SpecificationGenerator : IIncrementalGenerator +{ + private const string GenerateSpecificationRegistryAttributeName = "PatternKit.Generators.Specification.GenerateSpecificationRegistryAttribute"; + private const string SpecificationRuleAttributeName = "PatternKit.Generators.Specification.SpecificationRuleAttribute"; + + private static readonly SymbolDisplayFormat TypeFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKSPEC001", + "Specification registry host must be partial", + "Type '{0}' is marked with [GenerateSpecificationRegistry] but is not declared as partial", + "PatternKit.Generators.Specification", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingRules = new( + "PKSPEC002", + "Specification registry has no rules", + "Type '{0}' is marked with [GenerateSpecificationRegistry] but does not declare any specification rules", + "PatternKit.Generators.Specification", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidRule = new( + "PKSPEC003", + "Specification rule signature is invalid", + "Rule method '{0}' must be static, return bool, and accept exactly one candidate parameter", + "PatternKit.Generators.Specification", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor DuplicateRule = new( + "PKSPEC004", + "Specification rule is duplicated", + "Specification rule '{0}' is registered more than once", + "PatternKit.Generators.Specification", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + GenerateSpecificationRegistryAttributeName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(static a => + a.AttributeClass?.ToDisplayString() == GenerateSpecificationRegistryAttributeName); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate( + SourceProductionContext context, + INamedTypeSymbol type, + TypeDeclarationSyntax node, + AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var candidateType = attribute.ConstructorArguments.Length >= 1 + ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol + : null; + if (candidateType is null) + return; + + var rules = GetRules(type, candidateType, context, out var hasAnnotatedRules); + if (!hasAnnotatedRules) + { + context.ReportDiagnostic(Diagnostic.Create(MissingRules, node.Identifier.GetLocation(), type.Name)); + return; + } + + if (rules.Length == 0) + return; + + if (TryFindDuplicate(rules, out var duplicate)) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateRule, duplicate.Location, duplicate.Name)); + return; + } + + var factoryMethodName = GetNamedString(attribute, "FactoryMethodName") ?? "Create"; + context.AddSource($"{type.Name}.SpecificationRegistry.g.cs", SourceText.From( + GenerateSource(type, candidateType, rules, factoryMethodName), + Encoding.UTF8)); + } + + private static ImmutableArray GetRules( + INamedTypeSymbol type, + INamedTypeSymbol candidateType, + SourceProductionContext context, + out bool hasAnnotatedRules) + { + hasAnnotatedRules = false; + var builder = ImmutableArray.CreateBuilder(); + foreach (var method in type.GetMembers().OfType()) + { + foreach (var attr in method.GetAttributes()) + { + if (attr.AttributeClass?.ToDisplayString() != SpecificationRuleAttributeName) + continue; + + hasAnnotatedRules = true; + if (!TryGetRule(method, attr, candidateType, out var rule)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidRule, method.Locations.FirstOrDefault(), method.Name)); + continue; + } + + builder.Add(rule); + } + } + + return builder.ToImmutable(); + } + + private static bool TryGetRule( + IMethodSymbol method, + AttributeData attribute, + INamedTypeSymbol candidateType, + out Rule rule) + { + rule = default; + var name = attribute.ConstructorArguments.Length == 1 + ? attribute.ConstructorArguments[0].Value as string + : null; + if (string.IsNullOrWhiteSpace(name)) + return false; + + if (!method.IsStatic || method.IsGenericMethod || method.ReturnsVoid || method.ReturnType.SpecialType != SpecialType.System_Boolean) + return false; + + if (method.Parameters.Length != 1 || !SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, candidateType)) + return false; + + rule = new Rule(name!, method.Name, method.Locations.FirstOrDefault()); + return true; + } + + private static bool TryFindDuplicate(IReadOnlyList rules, out Rule duplicate) + { + var seen = new HashSet(System.StringComparer.Ordinal); + foreach (var rule in rules) + { + if (!seen.Add(rule.Name)) + { + duplicate = rule; + return true; + } + } + + duplicate = default; + return false; + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol candidateType, + IReadOnlyList rules, + string factoryMethodName) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Application.Specification.SpecificationRegistry<") + .Append(candidateType.ToDisplayString(TypeFormat)) + .Append("> ") + .Append(factoryMethodName) + .AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" var builder = global::PatternKit.Application.Specification.SpecificationRegistry<") + .Append(candidateType.ToDisplayString(TypeFormat)) + .AppendLine(">.Create();"); + + foreach (var rule in rules.OrderBy(static rule => rule.Name, System.StringComparer.Ordinal)) + { + sb.Append(" builder.Add(\"") + .Append(Escape(rule.Name)) + .Append("\", static candidate => ") + .Append(rule.MethodName) + .AppendLine("(candidate));"); + } + + sb.AppendLine(" return builder.Build();"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private readonly record struct Rule(string Name, string MethodName, Location? Location); +} diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index 2121cf67..1a678b2d 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -87,6 +87,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() var cqrs = provider.GetRequiredService(); var checkout = provider.GetRequiredService(); var interpreter = provider.GetRequiredService(); + var specifications = provider.GetRequiredService(); auth.Chain.Execute(new PatternKit.Examples.Chain.HttpRequest("GET", "/admin/metrics", new Dictionary())); @@ -146,7 +147,8 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("CQRS generated path matches command writes to query reads", cqrsGenerated.QueryMatchedCommand), ("resilient checkout succeeds", checkout.Run(CreateCheckoutRequest(), new PatternKit.Examples.Messaging.CheckoutServices()).Succeeded), ("generated interpreter computes tier discounts", interpreter.Pricing.Interpret(PatternKit.Examples.InterpreterDemo.InterpreterDemo.TierDiscountRule, new PatternKit.Examples.InterpreterDemo.InterpreterDemo.PricingContext { CartTotal = 100m, CustomerTier = "Gold" }) == 10m), - ("generated interpreter evaluates VIP eligibility", interpreter.Eligibility.Interpret(PatternKit.Examples.InterpreterDemo.InterpreterDemo.VipEligibilityRule, new PatternKit.Examples.InterpreterDemo.InterpreterDemo.PricingContext { CartTotal = 150m, CustomerTier = "Gold" })) + ("generated interpreter evaluates VIP eligibility", interpreter.Eligibility.Interpret(PatternKit.Examples.InterpreterDemo.InterpreterDemo.VipEligibilityRule, new PatternKit.Examples.InterpreterDemo.InterpreterDemo.PricingContext { CartTotal = 150m, CustomerTier = "Gold" })), + ("generated specification registry approves prime loans", specifications.Service.Evaluate(PatternKit.Examples.SpecificationDemo.LoanApprovalSpecificationDemo.CreatePrimeApplication()).Approved) ]; } diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 4680611a..63ce71c0 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -51,7 +51,8 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Outbox", "Request-Reply", "Publish-Subscribe", - "CQRS" + "CQRS", + "Specification" ]; [Scenario("Catalog covers every canonical GoF pattern")] @@ -94,7 +95,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() { ScenarioExpect.Equal(10, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); - ScenarioExpect.Equal(1, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(2, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Examples.Tests/SpecificationDemo/LoanApprovalSpecificationDemoTests.cs b/test/PatternKit.Examples.Tests/SpecificationDemo/LoanApprovalSpecificationDemoTests.cs new file mode 100644 index 00000000..3491568d --- /dev/null +++ b/test/PatternKit.Examples.Tests/SpecificationDemo/LoanApprovalSpecificationDemoTests.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.SpecificationDemo; +using TinyBDD; +using static PatternKit.Examples.SpecificationDemo.LoanApprovalSpecificationDemo; + +namespace PatternKit.Examples.Tests.SpecificationDemo; + +public sealed class LoanApprovalSpecificationDemoTests +{ + [Scenario("Fluent and generated specifications approve the same prime application")] + [Fact] + public void Fluent_And_Generated_Specifications_Approve_The_Same_Prime_Application() + { + var application = CreatePrimeApplication(); + var fluent = CreateFluentRegistry(); + var generated = CreateGeneratedRegistry(); + + var fluentDecision = Evaluate(application, fluent); + var generatedDecision = Evaluate(application, generated); + + ScenarioExpect.True(fluentDecision.Approved); + ScenarioExpect.Equal(fluentDecision, generatedDecision); + ScenarioExpect.Equal(["affordable", "approval-ready", "clear-fraud", "prime-credit", "stable-income", "verified-identity"], generated.Names.OrderBy(static name => name).ToArray()); + } + + [Scenario("Generated specifications explain failed loan rules")] + [Fact] + public void Generated_Specifications_Explain_Failed_Loan_Rules() + { + var application = new LoanApplication("APP-300", 90_000m, 50_000m, 680, 4, false, false); + var registry = CreateGeneratedRegistry(); + + var decision = Evaluate(application, registry); + + ScenarioExpect.False(decision.Approved); + ScenarioExpect.Equal(["affordable", "prime-credit", "stable-income", "verified-identity"], decision.FailedRules.OrderBy(static name => name).ToArray()); + } + + [Scenario("Loan approval specifications integrate with IServiceCollection")] + [Fact] + public void Loan_Approval_Specifications_Integrate_With_IServiceCollection() + { + var services = new ServiceCollection(); + services.AddLoanApprovalSpecifications(); + using var provider = services.BuildServiceProvider(validateScopes: true); + + var service = provider.GetRequiredService(); + var approved = service.Evaluate(CreatePrimeApplication()); + var held = service.Evaluate(CreateHeldApplication()); + + ScenarioExpect.True(approved.Approved); + ScenarioExpect.False(held.Approved); + ScenarioExpect.Contains("clear-fraud", held.FailedRules); + } +} diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 82409ccf..89d65681 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -15,6 +15,7 @@ using PatternKit.Generators.Prototype; using PatternKit.Generators.Proxy; using PatternKit.Generators.Singleton; +using PatternKit.Generators.Specification; using PatternKit.Generators.State; using PatternKit.Generators.Template; using PatternKit.Generators.Visitors; @@ -120,6 +121,8 @@ private enum TestTrigger { typeof(ProxyIgnoreAttribute), AttributeTargets.Method | AttributeTargets.Property, false, false }, { typeof(SingletonAttribute), AttributeTargets.Class, false, false }, { typeof(SingletonFactoryAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateSpecificationRegistryAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(SpecificationRuleAttribute), AttributeTargets.Method, false, false }, { typeof(StateMachineAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(StateTransitionAttribute), AttributeTargets.Method, true, false }, { typeof(StateGuardAttribute), AttributeTargets.Method, true, false }, @@ -148,6 +151,23 @@ public void AttributeUsage_Is_Declared_As_Expected( ScenarioExpect.Equal(inherited, usage.Inherited); } + [Scenario("Specification Attributes Expose Defaults And Validation")] + [Fact] + public void Specification_Attributes_Expose_Defaults_And_Validation() + { + var generator = new GenerateSpecificationRegistryAttribute(typeof(string)) + { + FactoryMethodName = "BuildRegistry" + }; + var rule = new SpecificationRuleAttribute("approved"); + + ScenarioExpect.Equal(typeof(string), generator.CandidateType); + ScenarioExpect.Equal("BuildRegistry", generator.FactoryMethodName); + ScenarioExpect.Equal("approved", rule.Name); + ScenarioExpect.Throws(() => new GenerateSpecificationRegistryAttribute(null!)); + ScenarioExpect.Throws(() => new SpecificationRuleAttribute("")); + } + [Scenario("Interpreter Attributes Expose Defaults And Validation")] [Fact] public void Interpreter_Attributes_Expose_Defaults_And_Validation() diff --git a/test/PatternKit.Generators.Tests/SpecificationGeneratorTests.cs b/test/PatternKit.Generators.Tests/SpecificationGeneratorTests.cs new file mode 100644 index 00000000..1de40092 --- /dev/null +++ b/test/PatternKit.Generators.Tests/SpecificationGeneratorTests.cs @@ -0,0 +1,183 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.Specification; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class SpecificationGeneratorTests +{ + [Scenario("Generates specification registry from rule methods")] + [Fact] + public void GeneratesSpecificationRegistryFromRuleMethods() + { + var source = """ + using PatternKit.Generators.Specification; + + namespace Demo; + + public sealed record LoanApplication(decimal Income, int CreditScore); + + [GenerateSpecificationRegistry(typeof(LoanApplication), FactoryMethodName = "Build")] + public static partial class LoanRules + { + [SpecificationRule("high-income")] + private static bool HighIncome(LoanApplication application) => application.Income >= 100000m; + + [SpecificationRule("prime-credit")] + private static bool PrimeCredit(LoanApplication application) => application.CreditScore >= 720; + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesSpecificationRegistryFromRuleMethods)); + var gen = new SpecificationGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources)); + ScenarioExpect.Equal("LoanRules.SpecificationRegistry.g.cs", generated.HintName); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("Build()", text); + ScenarioExpect.Contains("builder.Add(\"high-income\", static candidate => HighIncome(candidate));", text); + ScenarioExpect.Contains("builder.Add(\"prime-credit\", static candidate => PrimeCredit(candidate));", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("Reports diagnostic for non-partial specification host")] + [Fact] + public void ReportsDiagnosticForNonPartialSpecificationHost() + { + var source = """ + using PatternKit.Generators.Specification; + + namespace Demo; + + [GenerateSpecificationRegistry(typeof(object))] + public static class Rules; + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForNonPartialSpecificationHost)); + + ScenarioExpect.Equal("PKSPEC001", diagnostic.Id); + } + + [Scenario("Reports diagnostic for specification host without rules")] + [Fact] + public void ReportsDiagnosticForSpecificationHostWithoutRules() + { + var source = """ + using PatternKit.Generators.Specification; + + namespace Demo; + + [GenerateSpecificationRegistry(typeof(object))] + public static partial class Rules; + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForSpecificationHostWithoutRules)); + + ScenarioExpect.Equal("PKSPEC002", diagnostic.Id); + } + + [Scenario("Reports diagnostic for invalid specification rule signature")] + [Fact] + public void ReportsDiagnosticForInvalidSpecificationRuleSignature() + { + var source = """ + using PatternKit.Generators.Specification; + + namespace Demo; + + [GenerateSpecificationRegistry(typeof(object))] + public static partial class Rules + { + [SpecificationRule("broken")] + private static string Broken(object candidate) => "no"; + } + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForInvalidSpecificationRuleSignature)); + + ScenarioExpect.Equal("PKSPEC003", diagnostic.Id); + } + + [Scenario("Reports diagnostic for duplicate specification rule names")] + [Fact] + public void ReportsDiagnosticForDuplicateSpecificationRuleNames() + { + var source = """ + using PatternKit.Generators.Specification; + + namespace Demo; + + [GenerateSpecificationRegistry(typeof(object))] + public static partial class Rules + { + [SpecificationRule("approved")] + private static bool Approved(object candidate) => true; + + [SpecificationRule("approved")] + private static bool AlsoApproved(object candidate) => false; + } + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForDuplicateSpecificationRuleNames)); + + ScenarioExpect.Equal("PKSPEC004", diagnostic.Id); + } + + [Scenario("Generates specification registry for global struct host and escaped names")] + [Fact] + public void GeneratesSpecificationRegistryForGlobalStructHostAndEscapedNames() + { + var source = """ + using PatternKit.Generators.Specification; + + [GenerateSpecificationRegistry(typeof(string))] + public partial struct Rules + { + [SpecificationRule("quote\"rule")] + private static bool Quoted(string candidate) => candidate.Length > 0; + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesSpecificationRegistryForGlobalStructHostAndEscapedNames)); + var gen = new SpecificationGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources)); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("partial struct Rules", text); + ScenarioExpect.DoesNotContain("namespace ", text); + ScenarioExpect.Contains("quote\\\"rule", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: + [ + MetadataReference.CreateFromFile(GetAbstractionsAssemblyPath()), + MetadataReference.CreateFromFile(typeof(PatternKit.Application.Specification.SpecificationRegistry<>).Assembly.Location) + ]); + + private static string GetAbstractionsAssemblyPath() + => Path.Combine( + Path.GetDirectoryName(typeof(SpecificationGenerator).Assembly.Location)!, + "PatternKit.Generators.Abstractions.dll"); + + private static Diagnostic RunAndGetSingleDiagnostic(string source, string assemblyName) + { + var comp = CreateCompilation(source, assemblyName); + var gen = new SpecificationGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + return ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + } +} diff --git a/test/PatternKit.Tests/Application/Specification/SpecificationTests.cs b/test/PatternKit.Tests/Application/Specification/SpecificationTests.cs new file mode 100644 index 00000000..3ec5f86e --- /dev/null +++ b/test/PatternKit.Tests/Application/Specification/SpecificationTests.cs @@ -0,0 +1,66 @@ +using PatternKit.Application.Specification; +using TinyBDD; + +namespace PatternKit.Tests.Application.Specification; + +public sealed class SpecificationTests +{ + private sealed record Applicant(string Tier, decimal Income, int CreditScore, bool HasFraudHold); + + [Scenario("Specification Evaluates Predicate And Exposes Predicate Delegate")] + [Fact] + public void Specification_Evaluates_Predicate_And_Exposes_Predicate_Delegate() + { + var spec = Specification.Where("high-credit", static applicant => applicant.CreditScore >= 700); + var applicant = new Applicant("standard", 80_000m, 720, false); + + ScenarioExpect.Equal("high-credit", spec.Name); + ScenarioExpect.True(spec.IsSatisfiedBy(applicant)); + ScenarioExpect.True(spec.ToPredicate()(applicant)); + } + + [Scenario("Specification Composes And Or Not")] + [Fact] + public void Specification_Composes_And_Or_Not() + { + var highIncome = Specification.Where("high-income", static applicant => applicant.Income >= 100_000m); + var goldTier = Specification.Where("gold-tier", static applicant => applicant.Tier == "Gold"); + var fraudHold = Specification.Where("fraud-hold", static applicant => applicant.HasFraudHold); + var eligible = highIncome.Or(goldTier, "income-or-tier").And(fraudHold.Not("clear"), "eligible"); + + ScenarioExpect.True(eligible.IsSatisfiedBy(new Applicant("Gold", 50_000m, 650, false))); + ScenarioExpect.True(eligible.IsSatisfiedBy(new Applicant("standard", 120_000m, 650, false))); + ScenarioExpect.False(eligible.IsSatisfiedBy(new Applicant("Gold", 120_000m, 650, true))); + ScenarioExpect.False(eligible.IsSatisfiedBy(new Applicant("standard", 50_000m, 650, false))); + } + + [Scenario("Specification Registry Resolves Named Rules")] + [Fact] + public void Specification_Registry_Resolves_Named_Rules() + { + var registry = SpecificationRegistry.Create() + .Add("all", Specification.All()) + .Add("none", Specification.None()) + .Add("prime", static applicant => applicant.CreditScore >= 720 && applicant.Income >= 75_000m) + .Build(); + var applicant = new Applicant("standard", 80_000m, 730, false); + + ScenarioExpect.Equal(["all", "none", "prime"], registry.Names.OrderBy(static name => name).ToArray()); + ScenarioExpect.True(registry.Get("prime").IsSatisfiedBy(applicant)); + ScenarioExpect.True(registry.IsSatisfiedBy("all", applicant)); + ScenarioExpect.False(registry.IsSatisfiedBy("none", applicant)); + ScenarioExpect.Throws(() => registry.Get("missing")); + } + + [Scenario("Specification Rejects Invalid Construction")] + [Fact] + public void Specification_Rejects_Invalid_Construction() + { + ScenarioExpect.Throws(() => Specification.Where("", static _ => true)); + ScenarioExpect.Throws(() => Specification.Where("rule", null!)); + ScenarioExpect.Throws(() => Specification.All().And(null!)); + ScenarioExpect.Throws(() => Specification.All().Or(null!)); + ScenarioExpect.Throws(() => SpecificationRegistry.Create().Add("", static _ => true)); + ScenarioExpect.Throws(() => SpecificationRegistry.Create().Add("rule", (ISpecification)null!)); + } +}