diff --git a/docs/examples/fulfillment-priority-queue.md b/docs/examples/fulfillment-priority-queue.md new file mode 100644 index 00000000..c18549e0 --- /dev/null +++ b/docs/examples/fulfillment-priority-queue.md @@ -0,0 +1,14 @@ +# Fulfillment Priority Queue + +The fulfillment priority queue example schedules enterprise or expedited orders ahead of standard fulfillment work. + +```csharp +services.AddFulfillmentPriorityQueueDemo(); + +var service = provider.GetRequiredService(); +var summary = service.Schedule( + new FulfillmentPriorityWork("order-standard", "standard", expedited: false), + new FulfillmentPriorityWork("order-enterprise", "enterprise", expedited: false)); +``` + +The example includes fluent and source-generated construction, stable ordering for matching priorities, and `IServiceCollection` registration for existing .NET applications. diff --git a/docs/examples/index.md b/docs/examples/index.md index 734e66df..a1074fdd 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -78,6 +78,9 @@ Welcome! This section collects small, focused demos that show **how to compose b * **Inventory Service Activator** Shows fluent and source-generated message-to-service activation with an importable `IServiceCollection` extension. See [Inventory Service Activator](inventory-service-activator.md). +* **Fulfillment Priority Queue** + Shows fluent and source-generated business-priority queues with an importable `IServiceCollection` extension. See [Fulfillment Priority Queue](fulfillment-priority-queue.md). + * **Generated Message Envelope** Shows fluent and source-generated message envelope contracts side by side, with an importable `IServiceCollection` extension. See [Generated Message Envelope](generated-message-envelope.md). diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 407bd75d..d164b2f5 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -214,6 +214,9 @@ - name: Fulfillment Queue Load Leveling href: fulfillment-queue-load-leveling.md +- name: Fulfillment Priority Queue + href: fulfillment-priority-queue.md + - name: Product Catalog Cache-Aside href: product-catalog-cache-aside.md diff --git a/docs/generators/index.md b/docs/generators/index.md index e122fbcc..5c362639 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -113,6 +113,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Circuit Breaker**](circuit-breaker.md) | Dependency isolation policy factories with open and half-open states | `[GenerateCircuitBreakerPolicy]` | | [**Bulkhead**](bulkhead.md) | Bounded concurrency and queue isolation policy factories | `[GenerateBulkheadPolicy]` | | [**Queue Load Leveling**](queue-load-leveling.md) | Bounded worker queue policy factories | `[GenerateQueueLoadLevelingPolicy]` | +| [**Priority Queue**](priority-queue.md) | Business-priority queue factories | `[GeneratePriorityQueue]` | | [**Cache-Aside**](cache-aside.md) | Read-through cache policy factories with TTL and cache predicates | `[GenerateCacheAsidePolicy]` | | [**Rate Limiting**](rate-limiting.md) | Key-partitioned fixed-window rate limit policy factories | `[GenerateRateLimitPolicy]` | | [**External Configuration Store**](external-configuration-store.md) | Typed centralized configuration loaders | `[GenerateExternalConfigurationStore]` | diff --git a/docs/generators/priority-queue.md b/docs/generators/priority-queue.md new file mode 100644 index 00000000..89118bf0 --- /dev/null +++ b/docs/generators/priority-queue.md @@ -0,0 +1,20 @@ +# Priority Queue Generator + +`[GeneratePriorityQueue]` creates a typed `PriorityQueuePolicy` factory from a priority selector method. + +```csharp +[GeneratePriorityQueue(typeof(FulfillmentPriorityWork), typeof(int), FactoryMethodName = "Create", QueueName = "fulfillment-priority")] +public static partial class FulfillmentPriorityQueue +{ + [PriorityQueuePrioritySelector] + private static int GetPriority(FulfillmentPriorityWork work) => work.Expedited ? 10 : 1; +} +``` + +The generated factory is parameterless, so applications can register it directly in `IServiceCollection` and inject the resulting queue into hosted workers or application services. + +Diagnostics: + +- `PKPQ001`: host type must be partial. +- `PKPQ002`: exactly one priority selector is required. +- `PKPQ003`: priority selector signature is invalid. diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index d3a9de0b..a7bb1ebc 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -151,6 +151,9 @@ - name: Queue Load Leveling href: queue-load-leveling.md +- name: Priority Queue + href: priority-queue.md + - name: Repository href: repository.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 8fd7b83a..86e7b774 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -78,6 +78,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Cloud Architecture | Circuit Breaker | `CircuitBreakerPolicy` | Circuit Breaker generator | | Cloud Architecture | Bulkhead | `BulkheadPolicy` | Bulkhead generator | | Cloud Architecture | Queue-Based Load Leveling | `QueueLoadLevelingPolicy` | Queue Load Leveling generator | +| Cloud Architecture | Priority Queue | `PriorityQueuePolicy` | Priority Queue generator | | Cloud Architecture | Cache-Aside | `CacheAsidePolicy` | Cache-Aside generator | | Cloud Architecture | Rate Limiting | `RateLimitPolicy` | Rate Limiting generator | | Cloud Architecture | External Configuration Store | `ExternalConfigurationStore` | External Configuration Store generator | diff --git a/docs/patterns/cloud/priority-queue.md b/docs/patterns/cloud/priority-queue.md new file mode 100644 index 00000000..665af205 --- /dev/null +++ b/docs/patterns/cloud/priority-queue.md @@ -0,0 +1,18 @@ +# Priority Queue + +Priority Queue schedules queued work by business priority instead of arrival order alone. + +```csharp +var queue = PriorityQueuePolicy + .Create("fulfillment-priority") + .WithPrioritySelector(work => work.Expedited ? 10 : 1) + .DequeueHighestPriorityFirst() + .Build(); + +queue.Enqueue(new FulfillmentPriorityWork("order-100", "enterprise", expedited: false)); +var next = queue.Dequeue(); +``` + +Use it when high-value, urgent, or otherwise prioritized work should be processed ahead of normal work while preserving FIFO ordering for matching priorities. The fluent API supports custom comparers and highest-first or lowest-first ordering. + +The source-generated path uses `[GeneratePriorityQueue]` and `[PriorityQueuePrioritySelector]`. Import the fulfillment example through `AddFulfillmentPriorityQueueDemo()` or `AddPatternKitExamples()`. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 9f703dd9..c1174150 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -355,6 +355,8 @@ href: cloud/bulkhead.md - name: Queue-Based Load Leveling href: cloud/queue-load-leveling.md + - name: Priority Queue + href: cloud/priority-queue.md - name: Cache-Aside href: cloud/cache-aside.md - name: Rate Limiting diff --git a/src/PatternKit.Core/Cloud/PriorityQueue/PriorityQueuePolicy.cs b/src/PatternKit.Core/Cloud/PriorityQueue/PriorityQueuePolicy.cs new file mode 100644 index 00000000..48e1d150 --- /dev/null +++ b/src/PatternKit.Core/Cloud/PriorityQueue/PriorityQueuePolicy.cs @@ -0,0 +1,187 @@ +namespace PatternKit.Cloud.PriorityQueue; + +public sealed class PriorityQueueEnqueueResult +{ + public PriorityQueueEnqueueResult(string queueName, TItem item, TPriority priority, int count) + => (QueueName, Item, Priority, Count) = (queueName, item, priority, count); + + public string QueueName { get; } + + public TItem Item { get; } + + public TPriority Priority { get; } + + public int Count { get; } +} + +public sealed class PriorityQueueDequeueResult +{ + private PriorityQueueDequeueResult(string queueName, TItem? item, TPriority? priority, bool hasItem, int remainingCount) + => (QueueName, Item, Priority, HasItem, RemainingCount) = (queueName, item, priority, hasItem, remainingCount); + + public string QueueName { get; } + + public TItem? Item { get; } + + public TPriority? Priority { get; } + + public bool HasItem { get; } + + public int RemainingCount { get; } + + public static PriorityQueueDequeueResult Empty(string queueName) + => new(queueName, default, default, false, 0); + + public static PriorityQueueDequeueResult ItemDequeued(string queueName, TItem item, TPriority priority, int remainingCount) + => new(queueName, item, priority, true, remainingCount); +} + +public sealed class PriorityQueuePolicy +{ + private readonly object _gate = new(); + private readonly List _items = []; + private readonly Func _prioritySelector; + private readonly IComparer _comparer; + private readonly bool _dequeueHighestPriorityFirst; + private long _nextSequence; + + private PriorityQueuePolicy( + string name, + Func prioritySelector, + IComparer comparer, + bool dequeueHighestPriorityFirst) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Priority queue name is required.", nameof(name)); + + Name = name; + _prioritySelector = prioritySelector ?? throw new ArgumentNullException(nameof(prioritySelector)); + _comparer = comparer ?? throw new ArgumentNullException(nameof(comparer)); + _dequeueHighestPriorityFirst = dequeueHighestPriorityFirst; + } + + public string Name { get; } + + public int Count + { + get + { + lock (_gate) + return _items.Count; + } + } + + public PriorityQueueEnqueueResult Enqueue(TItem item) + { + if (item is null) + throw new ArgumentNullException(nameof(item)); + + var priority = _prioritySelector(item); + lock (_gate) + { + _items.Add(new(item, priority, _nextSequence++)); + return new(Name, item, priority, _items.Count); + } + } + + public PriorityQueueDequeueResult Dequeue() + { + lock (_gate) + { + if (_items.Count == 0) + return PriorityQueueDequeueResult.Empty(Name); + + var index = FindBestIndex(); + var entry = _items[index]; + _items.RemoveAt(index); + return PriorityQueueDequeueResult.ItemDequeued(Name, entry.Item, entry.Priority, _items.Count); + } + } + + public PriorityQueueDequeueResult Peek() + { + lock (_gate) + { + if (_items.Count == 0) + return PriorityQueueDequeueResult.Empty(Name); + + var entry = _items[FindBestIndex()]; + return PriorityQueueDequeueResult.ItemDequeued(Name, entry.Item, entry.Priority, _items.Count); + } + } + + public static Builder Create(string name = "priority-queue") => new(name); + + private int FindBestIndex() + { + var best = 0; + for (var i = 1; i < _items.Count; i++) + { + var comparison = _comparer.Compare(_items[i].Priority, _items[best].Priority); + if (_dequeueHighestPriorityFirst ? comparison > 0 : comparison < 0) + { + best = i; + continue; + } + + if (comparison == 0 && _items[i].Sequence < _items[best].Sequence) + best = i; + } + + return best; + } + + public sealed class Builder + { + private readonly string _name; + private Func? _prioritySelector; + private IComparer _comparer = Comparer.Default; + private bool _dequeueHighestPriorityFirst = true; + + internal Builder(string name) => _name = name; + + public Builder WithPrioritySelector(Func prioritySelector) + { + _prioritySelector = prioritySelector ?? throw new ArgumentNullException(nameof(prioritySelector)); + return this; + } + + public Builder WithComparer(IComparer comparer) + { + _comparer = comparer ?? throw new ArgumentNullException(nameof(comparer)); + return this; + } + + public Builder DequeueHighestPriorityFirst() + { + _dequeueHighestPriorityFirst = true; + return this; + } + + public Builder DequeueLowestPriorityFirst() + { + _dequeueHighestPriorityFirst = false; + return this; + } + + public PriorityQueuePolicy Build() + { + if (_prioritySelector is null) + throw new InvalidOperationException("Priority queue requires a priority selector."); + + return new(_name, _prioritySelector, _comparer, _dequeueHighestPriorityFirst); + } + } + + private sealed class Entry + { + public Entry(TItem item, TPriority priority, long sequence) + => (Item, Priority, Sequence) = (item, priority, sequence); + + public TItem Item { get; } + + public TPriority Priority { get; } + + public long Sequence { get; } + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index a6758198..88b6f415 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ using PatternKit.Cloud.Bulkhead; using PatternKit.Cloud.CacheAside; using PatternKit.Cloud.CircuitBreaker; +using PatternKit.Cloud.PriorityQueue; using PatternKit.Cloud.RateLimiting; using PatternKit.Cloud.QueueLoadLeveling; using PatternKit.Cloud.Retry; @@ -43,6 +44,7 @@ using PatternKit.Examples.PointOfSale; using PatternKit.Examples.Pricing; using PatternKit.Examples.ProductionReadiness; +using PatternKit.Examples.PriorityQueueDemo; using PatternKit.Examples.PrototypeDemo; using PatternKit.Examples.ProxyDemo; using PatternKit.Examples.QueueLoadLevelingDemo; @@ -185,6 +187,7 @@ public sealed record InventoryRetryExample(RetryPolicy Policy public sealed record FulfillmentCircuitBreakerExample(CircuitBreakerPolicy Policy, FulfillmentCircuitBreakerService Service); public sealed record ShippingBulkheadExample(BulkheadPolicy Policy, ShippingBulkheadService Service); public sealed record FulfillmentQueueLoadLevelingExample(QueueLoadLevelingPolicy Policy, FulfillmentQueueLoadLevelingService Service); +public sealed record FulfillmentPriorityQueueExample(PriorityQueuePolicy Queue, FulfillmentPriorityQueueService Service); public sealed record ProductCatalogCacheAsideExample(CacheAsidePolicy Policy, ProductCatalogCacheAsideService Service); public sealed record ProductSearchRateLimitingExample(RateLimitPolicy Policy, ProductSearchRateLimitService Service); public sealed record TenantExternalConfigurationStoreExample(TenantExternalConfigurationStoreDemoRunner Runner, TenantExternalConfigurationService Service); @@ -269,6 +272,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddFulfillmentCircuitBreakerExample() .AddShippingBulkheadExample() .AddFulfillmentQueueLoadLevelingExample() + .AddFulfillmentPriorityQueueExample() .AddProductCatalogCacheAsideExample() .AddProductSearchRateLimitingExample() .AddTenantExternalConfigurationStoreExample(); @@ -928,6 +932,15 @@ public static IServiceCollection AddFulfillmentQueueLoadLevelingExample(this ISe return services.RegisterExample("Fulfillment Queue Load Leveling", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddFulfillmentPriorityQueueExample(this IServiceCollection services) + { + services.AddFulfillmentPriorityQueueDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService())); + return services.RegisterExample("Fulfillment Priority Queue", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddProductCatalogCacheAsideExample(this IServiceCollection services) { services.AddProductCatalogCacheAsideDemo(); diff --git a/src/PatternKit.Examples/PriorityQueueDemo/FulfillmentPriorityQueueDemo.cs b/src/PatternKit.Examples/PriorityQueueDemo/FulfillmentPriorityQueueDemo.cs new file mode 100644 index 00000000..572f2506 --- /dev/null +++ b/src/PatternKit.Examples/PriorityQueueDemo/FulfillmentPriorityQueueDemo.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Cloud.PriorityQueue; +using PatternKit.Generators.PriorityQueue; + +namespace PatternKit.Examples.PriorityQueueDemo; + +public sealed record FulfillmentPriorityWork(string OrderId, string CustomerTier, bool Expedited); + +public sealed record FulfillmentPrioritySummary(string QueueName, string FirstOrderId, int FirstPriority, int RemainingCount); + +public sealed class FulfillmentPriorityQueueService(PriorityQueuePolicy queue) +{ + public FulfillmentPrioritySummary Schedule(params FulfillmentPriorityWork[] work) + { + if (work is null) + throw new ArgumentNullException(nameof(work)); + + foreach (var item in work) + queue.Enqueue(item); + + var first = queue.Dequeue(); + if (!first.HasItem) + throw new InvalidOperationException("Priority queue returned no work."); + + return new(first.QueueName, first.Item!.OrderId, first.Priority!, first.RemainingCount); + } +} + +public static class FulfillmentPriorityQueues +{ + public static PriorityQueuePolicy CreateFluent() + => PriorityQueuePolicy.Create("fulfillment-priority") + .WithPrioritySelector(GetPriority) + .DequeueHighestPriorityFirst() + .Build(); + + public static int GetPriority(FulfillmentPriorityWork work) + { + var tierPriority = work.CustomerTier.Equals("enterprise", StringComparison.OrdinalIgnoreCase) ? 20 : 5; + var expeditePriority = work.Expedited ? 10 : 0; + return tierPriority + expeditePriority; + } +} + +[GeneratePriorityQueue(typeof(FulfillmentPriorityWork), typeof(int), FactoryMethodName = "Create", QueueName = "fulfillment-priority")] +public static partial class GeneratedFulfillmentPriorityQueue +{ + [PriorityQueuePrioritySelector] + private static int GetPriority(FulfillmentPriorityWork work) => FulfillmentPriorityQueues.GetPriority(work); +} + +public sealed class FulfillmentPriorityQueueDemoRunner(FulfillmentPriorityQueueService service) +{ + public FulfillmentPrioritySummary RunGenerated(params FulfillmentPriorityWork[] work) => service.Schedule(work); + + public static FulfillmentPrioritySummary RunFluent() + => RunWith(FulfillmentPriorityQueues.CreateFluent()); + + public static FulfillmentPrioritySummary RunGeneratedStatic() + => RunWith(GeneratedFulfillmentPriorityQueue.Create()); + + private static FulfillmentPrioritySummary RunWith(PriorityQueuePolicy queue) + => new FulfillmentPriorityQueueService(queue).Schedule( + new FulfillmentPriorityWork("order-standard", "standard", false), + new FulfillmentPriorityWork("order-expedited", "standard", true), + new FulfillmentPriorityWork("order-enterprise", "enterprise", false)); +} + +public static class FulfillmentPriorityQueueServiceCollectionExtensions +{ + public static IServiceCollection AddFulfillmentPriorityQueueDemo(this IServiceCollection services) + { + services.AddSingleton(static _ => GeneratedFulfillmentPriorityQueue.Create()); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index cd95348f..b448c318 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -664,6 +664,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, ["Queue-Based Load Leveling"], ["bounded worker queue", "source-generated policy factory", "DI composition"]), + Descriptor( + "Fulfillment Priority Queue", + "src/PatternKit.Examples/PriorityQueueDemo/FulfillmentPriorityQueueDemo.cs", + "test/PatternKit.Examples.Tests/PriorityQueueDemo/FulfillmentPriorityQueueDemoTests.cs", + "docs/examples/fulfillment-priority-queue.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["Priority Queue"], + ["business priority ordering", "source-generated priority queue factory", "DI composition"]), Descriptor( "Product Catalog Cache-Aside", "src/PatternKit.Examples/CacheAsideDemo/ProductCatalogCacheAsideDemo.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 26a6154b..228562e1 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -805,6 +805,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/QueueLoadLevelingDemo/FulfillmentQueueLoadLevelingDemoTests.cs", ["fluent bounded worker queue", "generated queue load leveling policy", "DI-importable fulfillment queue example"]), + Pattern("Priority Queue", PatternFamily.CloudArchitecture, + "docs/patterns/cloud/priority-queue.md", + "src/PatternKit.Core/Cloud/PriorityQueue/PriorityQueuePolicy.cs", + "test/PatternKit.Tests/Cloud/PriorityQueue/PriorityQueuePolicyTests.cs", + "docs/generators/priority-queue.md", + "src/PatternKit.Generators/PriorityQueue/PriorityQueueGenerator.cs", + "test/PatternKit.Generators.Tests/PriorityQueueGeneratorTests.cs", + null, + "docs/examples/fulfillment-priority-queue.md", + "src/PatternKit.Examples/PriorityQueueDemo/FulfillmentPriorityQueueDemo.cs", + "test/PatternKit.Examples.Tests/PriorityQueueDemo/FulfillmentPriorityQueueDemoTests.cs", + ["fluent business priority queue", "generated priority queue factory", "DI-importable fulfillment scheduling example"]), + Pattern("Cache-Aside", PatternFamily.CloudArchitecture, "docs/patterns/cloud/cache-aside.md", "src/PatternKit.Core/Cloud/CacheAside/CacheAsidePolicy.cs", diff --git a/src/PatternKit.Generators.Abstractions/Cloud/PriorityQueueAttributes.cs b/src/PatternKit.Generators.Abstractions/Cloud/PriorityQueueAttributes.cs new file mode 100644 index 00000000..2db0e0da --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Cloud/PriorityQueueAttributes.cs @@ -0,0 +1,20 @@ +namespace PatternKit.Generators.PriorityQueue; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GeneratePriorityQueueAttribute(Type itemType, Type priorityType) : Attribute +{ + public Type ItemType { get; } = itemType ?? throw new ArgumentNullException(nameof(itemType)); + + public Type PriorityType { get; } = priorityType ?? throw new ArgumentNullException(nameof(priorityType)); + + public string FactoryMethodName { get; set; } = "Create"; + + public string QueueName { get; set; } = "priority-queue"; + + public bool DequeueHighestPriorityFirst { get; set; } = true; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class PriorityQueuePrioritySelectorAttribute : Attribute +{ +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 00e1473e..370e901f 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -110,6 +110,9 @@ PKFT002 | PatternKit.Generators.FeatureToggles | Error | Feature Toggle set must PKFT003 | PatternKit.Generators.FeatureToggles | Error | Feature Toggle rule signature is invalid. PKQL001 | PatternKit.Generators.QueueLoadLeveling | Error | Queue Load Leveling policy host must be partial. PKQL002 | PatternKit.Generators.QueueLoadLeveling | Error | Queue Load Leveling policy configuration is invalid. +PKPQ001 | PatternKit.Generators.PriorityQueue | Error | Priority Queue host must be partial. +PKPQ002 | PatternKit.Generators.PriorityQueue | Error | Priority Queue must declare exactly one priority selector. +PKPQ003 | PatternKit.Generators.PriorityQueue | Error | Priority Queue priority selector signature is invalid. PKPRO001 | PatternKit.Generators.Prototype | Error | Type marked with [Prototype] must be partial PKPRO002 | PatternKit.Generators.Prototype | Error | Cannot construct clone target (no supported clone construction path) PKPRO003 | PatternKit.Generators.Prototype | Warning | Unsafe reference capture (mutable reference types) diff --git a/src/PatternKit.Generators/PriorityQueue/PriorityQueueGenerator.cs b/src/PatternKit.Generators/PriorityQueue/PriorityQueueGenerator.cs new file mode 100644 index 00000000..2dcc806a --- /dev/null +++ b/src/PatternKit.Generators/PriorityQueue/PriorityQueueGenerator.cs @@ -0,0 +1,157 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.PriorityQueue; + +[Generator] +public sealed class PriorityQueueGenerator : IIncrementalGenerator +{ + private const string AttributeName = "PatternKit.Generators.PriorityQueue.GeneratePriorityQueueAttribute"; + private const string SelectorAttributeName = "PatternKit.Generators.PriorityQueue.PriorityQueuePrioritySelectorAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKPQ001", "Priority Queue host must be partial", + "Type '{0}' is marked with [GeneratePriorityQueue] but is not declared as partial", + "PatternKit.Generators.PriorityQueue", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingSelector = new( + "PKPQ002", "Priority Queue priority selector is missing", + "Priority Queue type '{0}' must declare exactly one [PriorityQueuePrioritySelector] method", + "PatternKit.Generators.PriorityQueue", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidSelector = new( + "PKPQ003", "Priority Queue priority selector signature is invalid", + "Priority Queue selector '{0}' must be static and return TPriority with one TItem parameter", + "PatternKit.Generators.PriorityQueue", DiagnosticSeverity.Error, true); + + private static readonly SymbolDisplayFormat TypeFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + AttributeName, + 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() == AttributeName); + 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 itemType = attribute.ConstructorArguments.Length >= 1 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + var priorityType = attribute.ConstructorArguments.Length >= 2 ? attribute.ConstructorArguments[1].Value as INamedTypeSymbol : null; + if (itemType is null || priorityType is null) + return; + + var selectors = type.GetMembers().OfType().Where(static method => + method.GetAttributes().Any(static attr => attr.AttributeClass?.ToDisplayString() == SelectorAttributeName)).ToArray(); + if (selectors.Length != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingSelector, node.Identifier.GetLocation(), type.Name)); + return; + } + + if (!IsSelector(selectors[0], itemType, priorityType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidSelector, selectors[0].Locations.FirstOrDefault(), selectors[0].Name)); + return; + } + + context.AddSource($"{type.Name}.PriorityQueue.g.cs", SourceText.From(GenerateSource( + type, + itemType, + priorityType, + selectors[0].Name, + GetNamedString(attribute, "FactoryMethodName") ?? "Create", + GetNamedString(attribute, "QueueName") ?? "priority-queue", + GetNamedBool(attribute, "DequeueHighestPriorityFirst") ?? true), Encoding.UTF8)); + } + + private static bool IsSelector(IMethodSymbol method, INamedTypeSymbol itemType, INamedTypeSymbol priorityType) + => method.IsStatic && + SymbolEqualityComparer.Default.Equals(method.ReturnType, priorityType) && + method.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, itemType); + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol itemType, + INamedTypeSymbol priorityType, + string selectorName, + string factoryMethodName, + string queueName, + bool dequeueHighestPriorityFirst) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var itemTypeName = itemType.ToDisplayString(TypeFormat); + var priorityTypeName = priorityType.ToDisplayString(TypeFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + if (type.IsStatic) + sb.Append("static "); + else if (type.IsAbstract && type.TypeKind == TypeKind.Class) + sb.Append("abstract "); + else if (type.IsSealed && type.TypeKind == TypeKind.Class) + sb.Append("sealed "); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Cloud.PriorityQueue.PriorityQueuePolicy<").Append(itemTypeName).Append(", ").Append(priorityTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" return global::PatternKit.Cloud.PriorityQueue.PriorityQueuePolicy<").Append(itemTypeName).Append(", ").Append(priorityTypeName).Append(">.Create(\"").Append(Escape(queueName)).AppendLine("\")"); + sb.Append(" .WithPrioritySelector(").Append(selectorName).AppendLine(")"); + sb.AppendLine(dequeueHighestPriorityFirst + ? " .DequeueHighestPriorityFirst()" + : " .DequeueLowestPriorityFirst()"); + sb.AppendLine(" .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 static bool? GetNamedBool(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as bool?; + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; +} diff --git a/test/PatternKit.Examples.Tests/PriorityQueueDemo/FulfillmentPriorityQueueDemoTests.cs b/test/PatternKit.Examples.Tests/PriorityQueueDemo/FulfillmentPriorityQueueDemoTests.cs new file mode 100644 index 00000000..06c10aaf --- /dev/null +++ b/test/PatternKit.Examples.Tests/PriorityQueueDemo/FulfillmentPriorityQueueDemoTests.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.PriorityQueueDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.PriorityQueueDemo; + +[Feature("Fulfillment priority queue example")] +public sealed class FulfillmentPriorityQueueDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent and generated priority queues schedule urgent fulfillment first")] + [Fact] + public Task Fluent_And_Generated_Priority_Queues_Schedule_Urgent_Fulfillment_First() + => Given("fulfillment priority queue examples", () => new + { + Fluent = FulfillmentPriorityQueueDemoRunner.RunFluent(), + Generated = FulfillmentPriorityQueueDemoRunner.RunGeneratedStatic() + }) + .Then("both paths choose the enterprise order first", result => + { + ScenarioExpect.Equal("order-enterprise", result.Fluent.FirstOrderId); + ScenarioExpect.Equal("order-enterprise", result.Generated.FirstOrderId); + ScenarioExpect.Equal(20, result.Generated.FirstPriority); + ScenarioExpect.Equal("fulfillment-priority", result.Generated.QueueName); + }) + .AssertPassed(); + + [Scenario("Priority queue demo is importable through IServiceCollection")] + [Fact] + public Task Priority_Queue_Demo_Is_Importable_Through_IServiceCollection() + => Given("an importing app service provider", () => + { + var services = new ServiceCollection(); + services.AddFulfillmentPriorityQueueDemo(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("resolving and running the service", provider => + { + using (provider) + { + var service = provider.GetRequiredService(); + return service.Schedule( + new FulfillmentPriorityWork("order-standard", "standard", false), + new FulfillmentPriorityWork("order-enterprise", "enterprise", false)); + } + }) + .Then("the service schedules the highest priority work first", result => + { + ScenarioExpect.Equal("order-enterprise", result.FirstOrderId); + ScenarioExpect.Equal(1, result.RemainingCount); + }) + .AssertPassed(); + + [Scenario("Aggregate examples import priority queue demo")] + [Fact] + public Task Aggregate_Examples_Import_Priority_Queue_Demo() + => Given("a PatternKit examples service provider", () => + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("resolving the aggregate priority queue example", provider => + { + using (provider) + return provider.GetRequiredService(); + }) + .Then("the aggregate example exposes the queue and service", example => + { + ScenarioExpect.Equal("fulfillment-priority", example.Queue.Name); + ScenarioExpect.NotNull(example.Service); + }) + .AssertPassed(); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 553086e8..bf8a24e0 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -72,6 +72,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Circuit Breaker", "Bulkhead", "Queue-Based Load Leveling", + "Priority Queue", "Cache-Aside", "Rate Limiting", "External Configuration Store", @@ -132,7 +133,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() { ScenarioExpect.Equal(27, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); - ScenarioExpect.Equal(7, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); + ScenarioExpect.Equal(8, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); ScenarioExpect.Equal(15, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 51191f72..cc828114 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -24,6 +24,7 @@ using PatternKit.Generators.Observer; using PatternKit.Generators.Prototype; using PatternKit.Generators.Proxy; +using PatternKit.Generators.PriorityQueue; using PatternKit.Generators.QueueLoadLeveling; using PatternKit.Generators.RateLimiting; using PatternKit.Generators.Repository; @@ -202,6 +203,8 @@ private enum TestTrigger { typeof(PrototypeStrategyAttribute), AttributeTargets.Property | AttributeTargets.Field, false, false }, { typeof(GenerateProxyAttribute), AttributeTargets.Interface | AttributeTargets.Class, false, false }, { typeof(ProxyIgnoreAttribute), AttributeTargets.Method | AttributeTargets.Property, false, false }, + { typeof(GeneratePriorityQueueAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(PriorityQueuePrioritySelectorAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateQueueLoadLevelingPolicyAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GenerateRateLimitPolicyAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GenerateRepositoryAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, @@ -386,6 +389,27 @@ public void Bulkhead_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.Throws(() => new GenerateBulkheadPolicyAttribute(null!)); } + [Scenario("Priority Queue Attributes Expose Defaults And Configuration")] + [Fact] + public void PriorityQueue_Attributes_Expose_Defaults_And_Configuration() + { + var queue = new GeneratePriorityQueueAttribute(typeof(string), typeof(int)) + { + FactoryMethodName = "BuildFulfillmentPriority", + QueueName = "fulfillment-priority", + DequeueHighestPriorityFirst = false + }; + + ScenarioExpect.Equal(typeof(string), queue.ItemType); + ScenarioExpect.Equal(typeof(int), queue.PriorityType); + ScenarioExpect.Equal("BuildFulfillmentPriority", queue.FactoryMethodName); + ScenarioExpect.Equal("fulfillment-priority", queue.QueueName); + ScenarioExpect.False(queue.DequeueHighestPriorityFirst); + ScenarioExpect.Throws(() => new GeneratePriorityQueueAttribute(null!, typeof(int))); + ScenarioExpect.Throws(() => new GeneratePriorityQueueAttribute(typeof(string), null!)); + ScenarioExpect.IsType(new PriorityQueuePrioritySelectorAttribute()); + } + [Scenario("Queue Load Leveling Attributes Expose Defaults And Configuration")] [Fact] public void QueueLoadLeveling_Attributes_Expose_Defaults_And_Configuration() diff --git a/test/PatternKit.Generators.Tests/PriorityQueueGeneratorTests.cs b/test/PatternKit.Generators.Tests/PriorityQueueGeneratorTests.cs new file mode 100644 index 00000000..599ae727 --- /dev/null +++ b/test/PatternKit.Generators.Tests/PriorityQueueGeneratorTests.cs @@ -0,0 +1,117 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Cloud.PriorityQueue; +using PatternKit.Generators.PriorityQueue; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Priority Queue generator")] +public sealed partial class PriorityQueueGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates priority queue factory")] + [Fact] + public Task Generates_Priority_Queue_Factory() + => Given("a priority queue declaration", () => Compile(""" + using PatternKit.Generators.PriorityQueue; + namespace Demo; + public sealed record FulfillmentWork(string OrderId, int Priority); + [GeneratePriorityQueue(typeof(FulfillmentWork), typeof(int), FactoryMethodName = "Build", QueueName = "fulfillment-priority")] + public static partial class FulfillmentPriorityQueue + { + [PriorityQueuePrioritySelector] + private static int SelectPriority(FulfillmentWork item) => item.Priority; + } + """)) + .Then("the generated source creates the configured queue", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("Build()", source); + ScenarioExpect.Contains("PriorityQueuePolicy.Create(\"fulfillment-priority\")", source); + ScenarioExpect.Contains(".WithPrioritySelector(SelectPriority)", source); + ScenarioExpect.Contains(".DequeueHighestPriorityFirst()", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid priority queue declarations")] + [Fact] + public Task Reports_Diagnostics_For_Invalid_Priority_Queue_Declarations() + => Given("invalid priority queue declarations", () => new[] + { + Compile(""" + using PatternKit.Generators.PriorityQueue; + [GeneratePriorityQueue(typeof(string), typeof(int))] + public static class PriorityQueueHost; + """), + Compile(""" + using PatternKit.Generators.PriorityQueue; + [GeneratePriorityQueue(typeof(string), typeof(int))] + public static partial class PriorityQueueHost; + """), + Compile(""" + using PatternKit.Generators.PriorityQueue; + [GeneratePriorityQueue(typeof(string), typeof(int))] + public static partial class PriorityQueueHost + { + [PriorityQueuePrioritySelector] + private static string SelectPriority(string item) => item; + } + """) + }) + .Then("diagnostics identify the invalid declarations", results => + { + ScenarioExpect.Contains(results[0].Diagnostics, diagnostic => diagnostic.Id == "PKPQ001"); + ScenarioExpect.Contains(results[1].Diagnostics, diagnostic => diagnostic.Id == "PKPQ002"); + ScenarioExpect.Contains(results[2].Diagnostics, diagnostic => diagnostic.Id == "PKPQ003"); + }) + .AssertPassed(); + + [Scenario("Generates lowest-first priority queues with escaped names")] + [Fact] + public Task Generates_Lowest_First_Priority_Queues_With_Escaped_Names() + => Given("priority queue declaration with lowest-first ordering", () => Compile(""" + using PatternKit.Generators.PriorityQueue; + namespace Demo; + [GeneratePriorityQueue(typeof(string), typeof(int), QueueName = "queue\"" + "\\priority", DequeueHighestPriorityFirst = false)] + internal partial struct PriorityDefaults + { + [PriorityQueuePrioritySelector] + private static int SelectPriority(string item) => item.Length; + } + """)) + .Then("the generated source preserves configuration", result => + { + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Contains("internal partial struct PriorityDefaults", source); + ScenarioExpect.Contains("Create(\"queue\\\"\\\\priority\")", source); + ScenarioExpect.Contains(".DequeueLowestPriorityFirst()", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "PriorityQueueGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(PriorityQueuePolicy<,>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new PriorityQueueGenerator(), out var run, out var updated); + var result = run.Results.Single(); + var emit = updated.Emit(Stream.Null); + return new GeneratorResult( + result.Diagnostics.ToArray(), + result.GeneratedSources.Select(static source => source.SourceText.ToString()).ToArray(), + emit.Success, + emit.Diagnostics.Select(static diagnostic => diagnostic.ToString()).ToArray()); + } + + private sealed record GeneratorResult( + IReadOnlyList Diagnostics, + IReadOnlyList GeneratedSources, + bool EmitSuccess, + IReadOnlyList EmitDiagnostics); +} diff --git a/test/PatternKit.Tests/Cloud/PriorityQueue/PriorityQueuePolicyTests.cs b/test/PatternKit.Tests/Cloud/PriorityQueue/PriorityQueuePolicyTests.cs new file mode 100644 index 00000000..9120dc78 --- /dev/null +++ b/test/PatternKit.Tests/Cloud/PriorityQueue/PriorityQueuePolicyTests.cs @@ -0,0 +1,135 @@ +using PatternKit.Cloud.PriorityQueue; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Cloud.PriorityQueue; + +[Feature("Priority Queue")] +public sealed class PriorityQueuePolicyTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Priority queue dequeues highest priority first")] + [Fact] + public Task Priority_Queue_Dequeues_Highest_Priority_First() + => Given("a priority queue", () => PriorityQueuePolicy.Create("fulfillment-priority") + .WithPrioritySelector(static item => item.Priority) + .Build()) + .When("mixed priority work is enqueued", queue => + { + queue.Enqueue(new("standard", 1)); + queue.Enqueue(new("expedited", 5)); + return queue.Dequeue(); + }) + .Then("the highest priority item is returned first", result => + { + ScenarioExpect.True(result.HasItem); + ScenarioExpect.Equal("expedited", result.Item!.Name); + ScenarioExpect.Equal(5, result.Priority); + ScenarioExpect.Equal(1, result.RemainingCount); + }) + .AssertPassed(); + + [Scenario("Priority queue preserves FIFO order for matching priorities")] + [Fact] + public Task Priority_Queue_Preserves_Fifo_Order_For_Matching_Priorities() + => Given("a priority queue", () => PriorityQueuePolicy.Create() + .WithPrioritySelector(static item => item.Priority) + .Build()) + .When("same-priority work is enqueued", queue => + { + queue.Enqueue(new("first", 3)); + queue.Enqueue(new("second", 3)); + return new[] { queue.Dequeue(), queue.Dequeue() }; + }) + .Then("items with matching priority keep arrival order", results => + { + ScenarioExpect.Equal("first", results[0].Item!.Name); + ScenarioExpect.Equal("second", results[1].Item!.Name); + }) + .AssertPassed(); + + [Scenario("Priority queue can dequeue lowest priority first")] + [Fact] + public Task Priority_Queue_Can_Dequeue_Lowest_Priority_First() + => Given("a lowest-first priority queue", () => PriorityQueuePolicy.Create() + .WithPrioritySelector(static item => item.Priority) + .DequeueLowestPriorityFirst() + .Build()) + .When("mixed priority work is enqueued", queue => + { + queue.Enqueue(new("low", 1)); + queue.Enqueue(new("high", 9)); + return queue.Peek(); + }) + .Then("the lowest priority item is visible first", result => + ScenarioExpect.Equal("low", result.Item!.Name)) + .AssertPassed(); + + [Scenario("Priority queue reports enqueue metadata")] + [Fact] + public Task Priority_Queue_Reports_Enqueue_Metadata() + => Given("a priority queue", () => PriorityQueuePolicy.Create("support-priority") + .WithPrioritySelector(static item => item.Priority) + .Build()) + .When("work is enqueued", queue => queue.Enqueue(new("ticket", 7))) + .Then("the enqueue result reports queue priority item and count", result => + { + ScenarioExpect.Equal("support-priority", result.QueueName); + ScenarioExpect.Equal("ticket", result.Item.Name); + ScenarioExpect.Equal(7, result.Priority); + ScenarioExpect.Equal(1, result.Count); + }) + .AssertPassed(); + + [Scenario("Priority queue reports empty dequeue and peek")] + [Fact] + public Task Priority_Queue_Reports_Empty_Dequeue_And_Peek() + => Given("an empty priority queue", () => PriorityQueuePolicy.Create("empty-priority") + .WithPrioritySelector(static item => item.Priority) + .Build()) + .When("dequeueing and peeking", queue => new[] { queue.Dequeue(), queue.Peek() }) + .Then("both results are empty", results => + { + ScenarioExpect.False(results[0].HasItem); + ScenarioExpect.False(results[1].HasItem); + ScenarioExpect.Equal("empty-priority", results[0].QueueName); + ScenarioExpect.Equal(0, results[0].RemainingCount); + ScenarioExpect.Null(results[0].Item); + }) + .AssertPassed(); + + [Scenario("Priority queue supports custom priority comparers")] + [Fact] + public Task Priority_Queue_Supports_Custom_Priority_Comparers() + => Given("a priority queue with a custom comparer", () => PriorityQueuePolicy.Create() + .WithPrioritySelector(static item => item.Name) + .WithComparer(StringComparer.OrdinalIgnoreCase) + .Build()) + .When("case-varied priorities are enqueued", queue => + { + queue.Enqueue(new("alpha", 0)); + queue.Enqueue(new("Bravo", 0)); + return queue.Dequeue(); + }) + .Then("the comparer controls priority ordering", result => + ScenarioExpect.Equal("Bravo", result.Item!.Name)) + .AssertPassed(); + + [Scenario("Priority queue validates configuration")] + [Fact] + public Task Priority_Queue_Validates_Configuration() + => Given("invalid priority queue inputs", () => true) + .Then("invalid names are rejected", _ => + ScenarioExpect.Throws(() => PriorityQueuePolicy.Create("").WithPrioritySelector(static item => item.Priority).Build())) + .And("missing selectors are rejected", _ => + ScenarioExpect.Throws(() => PriorityQueuePolicy.Create().Build())) + .And("null selectors are rejected", _ => + ScenarioExpect.Throws(() => PriorityQueuePolicy.Create().WithPrioritySelector(null!))) + .And("null comparers are rejected", _ => + ScenarioExpect.Throws(() => PriorityQueuePolicy.Create().WithComparer(null!))) + .And("null items are rejected", _ => + ScenarioExpect.Throws(() => PriorityQueuePolicy.Create().WithPrioritySelector(static item => item.Length).Build().Enqueue(null!))) + .AssertPassed(); + + private sealed record WorkItem(string Name, int Priority); +}