From 459012f66c513cd1368849fee4fbe56acb9460c1 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 29 May 2026 22:21:03 -0500 Subject: [PATCH] test(generators): cover bulkhead and rate limiting --- .../Bulkhead/BulkheadPolicyGenerator.cs | 61 +++++- .../RateLimiting/RateLimitPolicyGenerator.cs | 59 ++++- .../BulkheadPolicyGeneratorTests.cs | 206 +++++++++++------- .../RateLimitPolicyGeneratorTests.cs | 203 ++++++++++------- 4 files changed, 348 insertions(+), 181 deletions(-) diff --git a/src/PatternKit.Generators/Bulkhead/BulkheadPolicyGenerator.cs b/src/PatternKit.Generators/Bulkhead/BulkheadPolicyGenerator.cs index a74f1c6a..4adb61f2 100644 --- a/src/PatternKit.Generators/Bulkhead/BulkheadPolicyGenerator.cs +++ b/src/PatternKit.Generators/Bulkhead/BulkheadPolicyGenerator.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using System.Text; using Microsoft.CodeAnalysis; @@ -100,6 +101,53 @@ private static string GenerateSource( sb.AppendLine(); } + var containingTypes = GetContainingTypes(type); + var indentLevel = 0; + foreach (var containingType in containingTypes) + { + AppendTypeDeclaration(sb, containingType, indentLevel); + sb.AppendLine(); + sb.AppendLine(new string(' ', indentLevel * 4) + "{"); + indentLevel++; + } + + AppendTypeDeclaration(sb, type, indentLevel); + sb.AppendLine(); + var indent = new string(' ', indentLevel * 4); + sb.AppendLine(indent + "{"); + var memberIndent = indent + " "; + var bodyIndent = memberIndent + " "; + sb.Append(memberIndent).Append("public static global::PatternKit.Cloud.Bulkhead.BulkheadPolicy<").Append(resultTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.AppendLine(memberIndent + "{"); + sb.Append(bodyIndent).Append("return global::PatternKit.Cloud.Bulkhead.BulkheadPolicy<").Append(resultTypeName).Append(">.Create(\"").Append(Escape(policyName)).AppendLine("\")"); + sb.Append(bodyIndent).Append(" .WithMaxConcurrency(").Append(maxConcurrency).AppendLine(")"); + sb.Append(bodyIndent).Append(" .WithMaxQueueLength(").Append(maxQueueLength).AppendLine(")"); + sb.Append(bodyIndent).Append(" .WithQueueTimeout(global::System.TimeSpan.FromMilliseconds(").Append(queueTimeoutMilliseconds).AppendLine("))"); + sb.Append(bodyIndent).AppendLine(" .Build();"); + sb.AppendLine(memberIndent + "}"); + sb.AppendLine(indent + "}"); + for (var i = containingTypes.Length - 1; i >= 0; i--) + { + sb.AppendLine(new string(' ', i * 4) + "}"); + } + + return sb.ToString(); + } + + private static INamedTypeSymbol[] GetContainingTypes(INamedTypeSymbol type) + { + var containingTypes = new Stack(); + for (var current = type.ContainingType; current is not null; current = current.ContainingType) + { + containingTypes.Push(current); + } + + return containingTypes.ToArray(); + } + + private static void AppendTypeDeclaration(StringBuilder sb, INamedTypeSymbol type, int indentLevel) + { + sb.Append(new string(' ', indentLevel * 4)); sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); if (type.IsStatic) sb.Append("static "); @@ -107,18 +155,7 @@ private static string GenerateSource( 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.Bulkhead.BulkheadPolicy<").Append(resultTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); - sb.AppendLine(" {"); - sb.Append(" return global::PatternKit.Cloud.Bulkhead.BulkheadPolicy<").Append(resultTypeName).Append(">.Create(\"").Append(Escape(policyName)).AppendLine("\")"); - sb.Append(" .WithMaxConcurrency(").Append(maxConcurrency).AppendLine(")"); - sb.Append(" .WithMaxQueueLength(").Append(maxQueueLength).AppendLine(")"); - sb.Append(" .WithQueueTimeout(global::System.TimeSpan.FromMilliseconds(").Append(queueTimeoutMilliseconds).AppendLine("))"); - sb.AppendLine(" .Build();"); - sb.AppendLine(" }"); - sb.AppendLine("}"); - return sb.ToString(); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name); } private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); diff --git a/src/PatternKit.Generators/RateLimiting/RateLimitPolicyGenerator.cs b/src/PatternKit.Generators/RateLimiting/RateLimitPolicyGenerator.cs index 4d0c1bac..ef09cb58 100644 --- a/src/PatternKit.Generators/RateLimiting/RateLimitPolicyGenerator.cs +++ b/src/PatternKit.Generators/RateLimiting/RateLimitPolicyGenerator.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using System.Text; using Microsoft.CodeAnalysis; @@ -98,6 +99,52 @@ private static string GenerateSource( sb.AppendLine(); } + var containingTypes = GetContainingTypes(type); + var indentLevel = 0; + foreach (var containingType in containingTypes) + { + AppendTypeDeclaration(sb, containingType, indentLevel); + sb.AppendLine(); + sb.AppendLine(new string(' ', indentLevel * 4) + "{"); + indentLevel++; + } + + AppendTypeDeclaration(sb, type, indentLevel); + sb.AppendLine(); + var indent = new string(' ', indentLevel * 4); + sb.AppendLine(indent + "{"); + var memberIndent = indent + " "; + var bodyIndent = memberIndent + " "; + sb.Append(memberIndent).Append("public static global::PatternKit.Cloud.RateLimiting.RateLimitPolicy<").Append(resultTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.AppendLine(memberIndent + "{"); + sb.Append(bodyIndent).Append("return global::PatternKit.Cloud.RateLimiting.RateLimitPolicy<").Append(resultTypeName).Append(">.Create(\"").Append(Escape(policyName)).AppendLine("\")"); + sb.Append(bodyIndent).Append(" .WithPermitLimit(").Append(permitLimit).AppendLine(")"); + sb.Append(bodyIndent).Append(" .WithWindow(global::System.TimeSpan.FromMilliseconds(").Append(windowMilliseconds).AppendLine("))"); + sb.Append(bodyIndent).AppendLine(" .Build();"); + sb.AppendLine(memberIndent + "}"); + sb.AppendLine(indent + "}"); + for (var i = containingTypes.Length - 1; i >= 0; i--) + { + sb.AppendLine(new string(' ', i * 4) + "}"); + } + + return sb.ToString(); + } + + private static INamedTypeSymbol[] GetContainingTypes(INamedTypeSymbol type) + { + var containingTypes = new Stack(); + for (var current = type.ContainingType; current is not null; current = current.ContainingType) + { + containingTypes.Push(current); + } + + return containingTypes.ToArray(); + } + + private static void AppendTypeDeclaration(StringBuilder sb, INamedTypeSymbol type, int indentLevel) + { + sb.Append(new string(' ', indentLevel * 4)); sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); if (type.IsStatic) sb.Append("static "); @@ -105,17 +152,7 @@ private static string GenerateSource( 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.RateLimiting.RateLimitPolicy<").Append(resultTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); - sb.AppendLine(" {"); - sb.Append(" return global::PatternKit.Cloud.RateLimiting.RateLimitPolicy<").Append(resultTypeName).Append(">.Create(\"").Append(Escape(policyName)).AppendLine("\")"); - sb.Append(" .WithPermitLimit(").Append(permitLimit).AppendLine(")"); - sb.Append(" .WithWindow(global::System.TimeSpan.FromMilliseconds(").Append(windowMilliseconds).AppendLine("))"); - sb.AppendLine(" .Build();"); - sb.AppendLine(" }"); - sb.AppendLine("}"); - return sb.ToString(); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name); } private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); diff --git a/test/PatternKit.Generators.Tests/BulkheadPolicyGeneratorTests.cs b/test/PatternKit.Generators.Tests/BulkheadPolicyGeneratorTests.cs index 117000ad..85538b18 100644 --- a/test/PatternKit.Generators.Tests/BulkheadPolicyGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/BulkheadPolicyGeneratorTests.cs @@ -2,100 +2,148 @@ using Microsoft.CodeAnalysis.CSharp; using PatternKit.Generators.Bulkhead; using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; namespace PatternKit.Generators.Tests; -public sealed class BulkheadPolicyGeneratorTests +[Feature("Bulkhead Policy generator")] +public sealed partial class BulkheadPolicyGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) { [Scenario("Generates bulkhead policy factory")] [Fact] - public void GeneratesBulkheadPolicyFactory() - { - var source = """ + public Task Generates_Bulkhead_Policy_Factory() + => Given("a configured bulkhead policy declaration", () => Compile(""" using PatternKit.Generators.Bulkhead; - namespace Demo; - [GenerateBulkheadPolicy(typeof(string), FactoryMethodName = "Build", PolicyName = "fulfillment", MaxConcurrency = 4, MaxQueueLength = 8, QueueTimeoutMilliseconds = 250)] public static partial class FulfillmentBulkhead; - """; - - var comp = CreateCompilation(source, nameof(GeneratesBulkheadPolicyFactory)); - var gen = new BulkheadPolicyGenerator(); - _ = 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.Equal("FulfillmentBulkhead.BulkheadPolicy.g.cs", generated.HintName); - ScenarioExpect.Contains("Build()", text); - ScenarioExpect.Contains("BulkheadPolicy.Create(\"fulfillment\")", text); - ScenarioExpect.Contains(".WithMaxConcurrency(4)", text); - ScenarioExpect.Contains(".WithMaxQueueLength(8)", text); - ScenarioExpect.Contains(".WithQueueTimeout(global::System.TimeSpan.FromMilliseconds(250))", text); - - var emit = updated.Emit(Stream.Null); - ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); - } - - [Scenario("Reports diagnostic for non-partial bulkhead host")] + """)) + .Then("generated source creates the configured policy", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Equal("FulfillmentBulkhead.BulkheadPolicy.g.cs", source.HintName); + ScenarioExpect.Contains("public static partial class FulfillmentBulkhead", source.Source); + ScenarioExpect.Contains("Build()", source.Source); + ScenarioExpect.Contains("BulkheadPolicy.Create(\"fulfillment\")", source.Source); + ScenarioExpect.Contains(".WithMaxConcurrency(4)", source.Source); + ScenarioExpect.Contains(".WithMaxQueueLength(8)", source.Source); + ScenarioExpect.Contains(".WithQueueTimeout(global::System.TimeSpan.FromMilliseconds(250))", source.Source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid bulkhead declarations")] + [Theory] + [InlineData("public static class BulkheadHost;", "PKBH001")] + [InlineData("public static partial class BulkheadHost;", "PKBH002", "MaxConcurrency = 0")] + [InlineData("public static partial class BulkheadHost;", "PKBH002", "MaxQueueLength = -1")] + [InlineData("public static partial class BulkheadHost;", "PKBH002", "QueueTimeoutMilliseconds = -1")] + public Task Reports_Diagnostics_For_Invalid_Bulkhead_Declarations(string declaration, string diagnosticId, string configuration = "") + => Given("an invalid bulkhead policy declaration", () => Compile($$""" + using PatternKit.Generators.Bulkhead; + [GenerateBulkheadPolicy(typeof(string){{(string.IsNullOrWhiteSpace(configuration) ? "" : ", " + configuration)}})] + {{declaration}} + """)) + .Then("the expected diagnostic is reported", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == diagnosticId)) + .AssertPassed(); + + [Scenario("Generates bulkhead defaults and host shapes")] [Fact] - public void ReportsDiagnosticForNonPartialBulkheadHost() - { - var source = """ + public Task Generates_Bulkhead_Defaults_And_Host_Shapes() + => Given("bulkhead policy declarations with default names and host shapes", () => Compile(""" using PatternKit.Generators.Bulkhead; - namespace Demo; [GenerateBulkheadPolicy(typeof(string))] - public static class BulkheadHost; - """; - - var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForNonPartialBulkheadHost)); - - ScenarioExpect.Equal("PKBH001", diagnostic.Id); - } - - [Scenario("Reports diagnostic for invalid bulkhead configuration")] + internal abstract partial class AbstractBulkhead; + + [GenerateBulkheadPolicy(typeof(string), PolicyName = "tenant\\\"bulkhead")] + public sealed partial class SealedBulkhead; + + [GenerateBulkheadPolicy(typeof(int))] + internal partial struct StructBulkhead; + """)) + .Then("generated sources preserve host shape and configured defaults", result => + { + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Equal(3, result.GeneratedSources.Count); + + var combined = string.Join("\n", result.GeneratedSources.Select(static source => source.Source)); + ScenarioExpect.Contains("internal abstract partial class AbstractBulkhead", combined); + ScenarioExpect.Contains("public sealed partial class SealedBulkhead", combined); + ScenarioExpect.Contains("internal partial struct StructBulkhead", combined); + ScenarioExpect.Contains("Create(\"bulkhead\")", combined); + ScenarioExpect.Contains("Create(\"tenant\\\\\\\"bulkhead\")", combined); + ScenarioExpect.Contains(".WithMaxConcurrency(8)", combined); + ScenarioExpect.Contains(".WithMaxQueueLength(0)", combined); + ScenarioExpect.Contains("FromMilliseconds(0)", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Generates nested bulkhead host wrappers")] [Fact] - public void ReportsDiagnosticForInvalidBulkheadConfiguration() - { - var source = """ + public Task Generates_Nested_Bulkhead_Host_Wrappers() + => Given("nested bulkhead policy declarations", () => Compile(""" using PatternKit.Generators.Bulkhead; - namespace Demo; - [GenerateBulkheadPolicy(typeof(string), MaxConcurrency = 0)] - public static partial class BulkheadHost; - """; - - var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForInvalidBulkheadConfiguration)); - - ScenarioExpect.Equal("PKBH002", diagnostic.Id); - } - - [Scenario("Generates bulkhead policy factory for global struct host")] + public partial class BulkheadContainer + { + private partial class PrivateHost + { + [GenerateBulkheadPolicy(typeof(string))] + protected partial class ProtectedBulkhead; + + [GenerateBulkheadPolicy(typeof(string))] + private protected partial class PrivateProtectedBulkhead; + + [GenerateBulkheadPolicy(typeof(string))] + protected internal partial class ProtectedInternalBulkhead; + } + } + """)) + .Then("generated sources preserve containing partial type wrappers", result => + { + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Equal(3, result.GeneratedSources.Count); + + var combined = string.Join("\n", result.GeneratedSources.Select(static source => source.Source)); + ScenarioExpect.Contains("public partial class BulkheadContainer", combined); + ScenarioExpect.Contains("private partial class PrivateHost", combined); + ScenarioExpect.Contains("protected partial class ProtectedBulkhead", combined); + ScenarioExpect.Contains("private protected partial class PrivateProtectedBulkhead", combined); + ScenarioExpect.Contains("protected internal partial class ProtectedInternalBulkhead", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Skips malformed bulkhead result type")] [Fact] - public void GeneratesBulkheadPolicyFactoryForGlobalStructHost() - { - var source = """ + public Task Skips_Malformed_Bulkhead_Result_Type() + => Given("a bulkhead policy declaration with a null result type", () => Compile(""" using PatternKit.Generators.Bulkhead; + [GenerateBulkheadPolicy(null!)] + public static partial class BulkheadHost; + """)) + .Then("no source is generated", result => + ScenarioExpect.Empty(result.GeneratedSources)) + .AssertPassed(); - [GenerateBulkheadPolicy(typeof(int), FactoryMethodName = "CreateNumbers", PolicyName = "numbers")] - internal partial struct BulkheadHost; - """; - - var comp = CreateCompilation(source, nameof(GeneratesBulkheadPolicyFactoryForGlobalStructHost)); - var gen = new BulkheadPolicyGenerator(); - _ = 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("internal partial struct BulkheadHost", text); - ScenarioExpect.Contains("CreateNumbers()", text); - ScenarioExpect.DoesNotContain("namespace Demo;", text); - ScenarioExpect.True(updated.Emit(Stream.Null).Success); + private static GeneratorResult Compile(string source) + { + var compilation = CreateCompilation(source, "BulkheadPolicyGeneratorTests"); + _ = RoslynTestHelpers.Run(compilation, new BulkheadPolicyGenerator(), 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 => new GeneratedSource(source.HintName, source.SourceText.ToString())).ToArray(), + emit.Success, + emit.Diagnostics.Select(static diagnostic => diagnostic.ToString()).ToArray()); } private static CSharpCompilation CreateCompilation(string source, string assemblyName) @@ -113,11 +161,11 @@ private static string GetAbstractionsAssemblyPath() Path.GetDirectoryName(typeof(BulkheadPolicyGenerator).Assembly.Location)!, "PatternKit.Generators.Abstractions.dll"); - private static Diagnostic RunAndGetSingleDiagnostic(string source, string assemblyName) - { - var comp = CreateCompilation(source, assemblyName); - var gen = new BulkheadPolicyGenerator(); - _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); - return ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); - } + private sealed record GeneratorResult( + IReadOnlyList Diagnostics, + IReadOnlyList GeneratedSources, + bool EmitSuccess, + IReadOnlyList EmitDiagnostics); + + private sealed record GeneratedSource(string HintName, string Source); } diff --git a/test/PatternKit.Generators.Tests/RateLimitPolicyGeneratorTests.cs b/test/PatternKit.Generators.Tests/RateLimitPolicyGeneratorTests.cs index 1bb9537c..7d015853 100644 --- a/test/PatternKit.Generators.Tests/RateLimitPolicyGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/RateLimitPolicyGeneratorTests.cs @@ -2,100 +2,145 @@ using Microsoft.CodeAnalysis.CSharp; using PatternKit.Generators.RateLimiting; using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; namespace PatternKit.Generators.Tests; -public sealed class RateLimitPolicyGeneratorTests +[Feature("Rate Limit Policy generator")] +public sealed partial class RateLimitPolicyGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) { [Scenario("Generates rate-limit policy factory")] [Fact] - public void GeneratesRateLimitPolicyFactory() - { - var source = """ + public Task Generates_Rate_Limit_Policy_Factory() + => Given("a configured rate-limit policy declaration", () => Compile(""" using PatternKit.Generators.RateLimiting; - namespace Demo; - [GenerateRateLimitPolicy(typeof(string), FactoryMethodName = "Build", PolicyName = "tenant-search", PermitLimit = 2, WindowMilliseconds = 1000)] public static partial class SearchRateLimit; - """; - - var comp = CreateCompilation(source, nameof(GeneratesRateLimitPolicyFactory)); - var gen = new RateLimitPolicyGenerator(); - _ = 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.Equal("SearchRateLimit.RateLimitPolicy.g.cs", generated.HintName); - ScenarioExpect.Contains("Build()", text); - ScenarioExpect.Contains("RateLimitPolicy.Create(\"tenant-search\")", text); - ScenarioExpect.Contains(".WithPermitLimit(2)", text); - ScenarioExpect.Contains(".WithWindow(global::System.TimeSpan.FromMilliseconds(1000))", text); - - var emit = updated.Emit(Stream.Null); - ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); - } - - [Scenario("Reports diagnostic for non-partial rate-limit host")] + """)) + .Then("generated source creates the configured policy", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Equal("SearchRateLimit.RateLimitPolicy.g.cs", source.HintName); + ScenarioExpect.Contains("public static partial class SearchRateLimit", source.Source); + ScenarioExpect.Contains("Build()", source.Source); + ScenarioExpect.Contains("RateLimitPolicy.Create(\"tenant-search\")", source.Source); + ScenarioExpect.Contains(".WithPermitLimit(2)", source.Source); + ScenarioExpect.Contains(".WithWindow(global::System.TimeSpan.FromMilliseconds(1000))", source.Source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid rate-limit declarations")] + [Theory] + [InlineData("public static class RateLimitHost;", "PKRLT001")] + [InlineData("public static partial class RateLimitHost;", "PKRLT002", "PermitLimit = 0")] + [InlineData("public static partial class RateLimitHost;", "PKRLT002", "WindowMilliseconds = 0")] + public Task Reports_Diagnostics_For_Invalid_Rate_Limit_Declarations(string declaration, string diagnosticId, string configuration = "") + => Given("an invalid rate-limit policy declaration", () => Compile($$""" + using PatternKit.Generators.RateLimiting; + [GenerateRateLimitPolicy(typeof(string){{(string.IsNullOrWhiteSpace(configuration) ? "" : ", " + configuration)}})] + {{declaration}} + """)) + .Then("the expected diagnostic is reported", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == diagnosticId)) + .AssertPassed(); + + [Scenario("Generates rate-limit defaults and host shapes")] [Fact] - public void ReportsDiagnosticForNonPartialRateLimitHost() - { - var source = """ + public Task Generates_Rate_Limit_Defaults_And_Host_Shapes() + => Given("rate-limit policy declarations with default names and host shapes", () => Compile(""" using PatternKit.Generators.RateLimiting; - namespace Demo; [GenerateRateLimitPolicy(typeof(string))] - public static class RateLimitHost; - """; - - var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForNonPartialRateLimitHost)); - - ScenarioExpect.Equal("PKRLT001", diagnostic.Id); - } - - [Scenario("Reports diagnostic for invalid rate-limit configuration")] + internal abstract partial class AbstractRateLimit; + + [GenerateRateLimitPolicy(typeof(string), PolicyName = "tenant\\\"search")] + public sealed partial class SealedRateLimit; + + [GenerateRateLimitPolicy(typeof(int))] + internal partial struct StructRateLimit; + """)) + .Then("generated sources preserve host shape and configured defaults", result => + { + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Equal(3, result.GeneratedSources.Count); + + var combined = string.Join("\n", result.GeneratedSources.Select(static source => source.Source)); + ScenarioExpect.Contains("internal abstract partial class AbstractRateLimit", combined); + ScenarioExpect.Contains("public sealed partial class SealedRateLimit", combined); + ScenarioExpect.Contains("internal partial struct StructRateLimit", combined); + ScenarioExpect.Contains("Create(\"rate-limit\")", combined); + ScenarioExpect.Contains("Create(\"tenant\\\\\\\"search\")", combined); + ScenarioExpect.Contains(".WithPermitLimit(60)", combined); + ScenarioExpect.Contains("FromMilliseconds(60000)", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Generates nested rate-limit host wrappers")] [Fact] - public void ReportsDiagnosticForInvalidRateLimitConfiguration() - { - var source = """ + public Task Generates_Nested_Rate_Limit_Host_Wrappers() + => Given("nested rate-limit policy declarations", () => Compile(""" using PatternKit.Generators.RateLimiting; - namespace Demo; - [GenerateRateLimitPolicy(typeof(string), PermitLimit = 0)] - public static partial class RateLimitHost; - """; - - var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForInvalidRateLimitConfiguration)); - - ScenarioExpect.Equal("PKRLT002", diagnostic.Id); - } - - [Scenario("Generates rate-limit policy factory for global struct host")] + public partial class RateLimitContainer + { + private partial class PrivateHost + { + [GenerateRateLimitPolicy(typeof(string))] + protected partial class ProtectedRateLimit; + + [GenerateRateLimitPolicy(typeof(string))] + private protected partial class PrivateProtectedRateLimit; + + [GenerateRateLimitPolicy(typeof(string))] + protected internal partial class ProtectedInternalRateLimit; + } + } + """)) + .Then("generated sources preserve containing partial type wrappers", result => + { + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Equal(3, result.GeneratedSources.Count); + + var combined = string.Join("\n", result.GeneratedSources.Select(static source => source.Source)); + ScenarioExpect.Contains("public partial class RateLimitContainer", combined); + ScenarioExpect.Contains("private partial class PrivateHost", combined); + ScenarioExpect.Contains("protected partial class ProtectedRateLimit", combined); + ScenarioExpect.Contains("private protected partial class PrivateProtectedRateLimit", combined); + ScenarioExpect.Contains("protected internal partial class ProtectedInternalRateLimit", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Skips malformed rate-limit result type")] [Fact] - public void GeneratesRateLimitPolicyFactoryForGlobalStructHost() - { - var source = """ + public Task Skips_Malformed_Rate_Limit_Result_Type() + => Given("a rate-limit policy declaration with a null result type", () => Compile(""" using PatternKit.Generators.RateLimiting; + [GenerateRateLimitPolicy(null!)] + public static partial class RateLimitHost; + """)) + .Then("no source is generated", result => + ScenarioExpect.Empty(result.GeneratedSources)) + .AssertPassed(); - [GenerateRateLimitPolicy(typeof(int), FactoryMethodName = "CreateNumbers", PolicyName = "numbers")] - internal partial struct RateLimitHost; - """; - - var comp = CreateCompilation(source, nameof(GeneratesRateLimitPolicyFactoryForGlobalStructHost)); - var gen = new RateLimitPolicyGenerator(); - _ = 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("internal partial struct RateLimitHost", text); - ScenarioExpect.Contains("CreateNumbers()", text); - ScenarioExpect.Contains(".WithPermitLimit(60)", text); - ScenarioExpect.DoesNotContain("namespace Demo;", text); - ScenarioExpect.True(updated.Emit(Stream.Null).Success); + private static GeneratorResult Compile(string source) + { + var compilation = CreateCompilation(source, "RateLimitPolicyGeneratorTests"); + _ = RoslynTestHelpers.Run(compilation, new RateLimitPolicyGenerator(), 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 => new GeneratedSource(source.HintName, source.SourceText.ToString())).ToArray(), + emit.Success, + emit.Diagnostics.Select(static diagnostic => diagnostic.ToString()).ToArray()); } private static CSharpCompilation CreateCompilation(string source, string assemblyName) @@ -113,11 +158,11 @@ private static string GetAbstractionsAssemblyPath() Path.GetDirectoryName(typeof(RateLimitPolicyGenerator).Assembly.Location)!, "PatternKit.Generators.Abstractions.dll"); - private static Diagnostic RunAndGetSingleDiagnostic(string source, string assemblyName) - { - var comp = CreateCompilation(source, assemblyName); - var gen = new RateLimitPolicyGenerator(); - _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); - return ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); - } + private sealed record GeneratorResult( + IReadOnlyList Diagnostics, + IReadOnlyList GeneratedSources, + bool EmitSuccess, + IReadOnlyList EmitDiagnostics); + + private sealed record GeneratedSource(string HintName, string Source); }