From f2c48354e31dd6b7ec65f62ad7b233e8e717b9f1 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 29 May 2026 13:23:33 -0500 Subject: [PATCH] test: cover messaging bridge generator hosts --- .../Messaging/MessagingBridgeGenerator.cs | 61 ++++-- .../MessagingBridgeGeneratorTests.cs | 190 ++++++++++++++---- 2 files changed, 204 insertions(+), 47 deletions(-) diff --git a/src/PatternKit.Generators/Messaging/MessagingBridgeGenerator.cs b/src/PatternKit.Generators/Messaging/MessagingBridgeGenerator.cs index 9e019594..b21affc7 100644 --- a/src/PatternKit.Generators/Messaging/MessagingBridgeGenerator.cs +++ b/src/PatternKit.Generators/Messaging/MessagingBridgeGenerator.cs @@ -89,6 +89,49 @@ 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); + var indent = new string(' ', indentLevel * 4); + sb.AppendLine(); + sb.AppendLine(indent + "{"); + var memberIndent = indent + " "; + var bodyIndent = memberIndent + " "; + sb.Append(memberIndent).Append("public static global::PatternKit.Messaging.Bridges.MessagingBridge<").Append(inbound).Append(", ").Append(outbound).Append(">.Builder ").Append(factoryName).AppendLine("()"); + sb.AppendLine(memberIndent + "{"); + sb.Append(bodyIndent).Append("return global::PatternKit.Messaging.Bridges.MessagingBridge<").Append(inbound).Append(", ").Append(outbound).Append(">.Create(").Append(ToLiteral(bridgeName)).AppendLine(");"); + 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 "); @@ -96,15 +139,14 @@ 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.Messaging.Bridges.MessagingBridge<").Append(inbound).Append(", ").Append(outbound).Append(">.Builder ").Append(factoryName).AppendLine("()"); - sb.Append(" => global::PatternKit.Messaging.Bridges.MessagingBridge<").Append(inbound).Append(", ").Append(outbound).Append(">.Create(").Append(ToLiteral(bridgeName)).AppendLine(");"); - sb.AppendLine("}"); - return sb.ToString(); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name); } + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static string ToLiteral(string value) => "@\"" + value.Replace("\"", "\"\"") + "\""; + private static string GetAccessibility(Accessibility accessibility) => accessibility switch { @@ -116,9 +158,4 @@ private static string GetAccessibility(Accessibility accessibility) Accessibility.ProtectedOrInternal => "protected internal", _ => "internal" }; - - private static string? GetNamedString(AttributeData attribute, string name) - => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; - - private static string ToLiteral(string value) => "@\"" + value.Replace("\"", "\"\"") + "\""; } diff --git a/test/PatternKit.Generators.Tests/MessagingBridgeGeneratorTests.cs b/test/PatternKit.Generators.Tests/MessagingBridgeGeneratorTests.cs index ccd844bf..4644fe83 100644 --- a/test/PatternKit.Generators.Tests/MessagingBridgeGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/MessagingBridgeGeneratorTests.cs @@ -11,8 +11,8 @@ public sealed partial class MessagingBridgeGeneratorTests(ITestOutputHelper outp { [Scenario("Generates messaging bridge factory")] [Fact] - public Task Generates_MessagingBridge_Factory() - => Given("a partial host marked with GenerateMessagingBridge", () => """ + public Task Generates_Messaging_Bridge_Factory() + => Given("a partial host marked with GenerateMessagingBridge", () => Compile(""" using PatternKit.Generators.Messaging; namespace MyApp; @@ -22,50 +22,170 @@ public sealed record CommerceOrder(string Id); [GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder), FactoryName = "Build", BridgeName = "partner-commerce")] public static partial class PartnerCommerceBridge; - """) - .When("the generator runs", source => - { - var comp = CreateCompilation(source, nameof(Generates_MessagingBridge_Factory)); - _ = RoslynTestHelpers.Run(comp, new MessagingBridgeGenerator(), out var run, out _); - return run.Results.Single().GeneratedSources.Single().SourceText.ToString(); - }) - .Then("the generated factory returns a configured builder", text => - { - ScenarioExpect.Contains("MessagingBridge.Builder Build()", text); - ScenarioExpect.Contains("MessagingBridge.Create(@\"partner-commerce\")", text); - }) - .AssertPassed(); + """)) + .Then("the generated factory returns a configured builder", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("public static partial class PartnerCommerceBridge", source); + ScenarioExpect.Contains("MessagingBridge.Builder Build()", source); + ScenarioExpect.Contains("MessagingBridge.Create(@\"partner-commerce\")", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostic for non-partial messaging bridge declarations")] + [Fact] + public Task Reports_Diagnostic_For_Non_Partial_Messaging_Bridge_Declarations() + => Given("a non-partial messaging bridge declaration", () => Compile(""" + using PatternKit.Generators.Messaging; - [Scenario("Reports messaging bridge diagnostics")] + public sealed record PartnerOrder(string Id); + public sealed record CommerceOrder(string Id); + + [GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder))] + public static class PartnerCommerceBridge; + """)) + .Then("the diagnostic identifies the host", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == "PKMBR001")) + .AssertPassed(); + + [Scenario("Reports diagnostic for invalid messaging bridge configuration")] [Theory] - [InlineData("[GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder))] public static class PartnerCommerceBridge;", "PKMBR001")] - [InlineData("[GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder), BridgeName = \"\")] public static partial class PartnerCommerceBridge;", "PKMBR002")] - public Task Reports_MessagingBridge_Diagnostics(string declaration, string expected) - => Given("an invalid GenerateMessagingBridge declaration", () => $$""" + [InlineData("FactoryName = \"\"", "PKMBR002")] + [InlineData("BridgeName = \"\"", "PKMBR002")] + [InlineData("FactoryName = \" \"", "PKMBR002")] + [InlineData("BridgeName = \" \"", "PKMBR002")] + public Task Reports_Diagnostic_For_Invalid_Messaging_Bridge_Configuration(string invalidConfiguration, string expected) + => Given("an invalid messaging bridge declaration", () => Compile($$""" using PatternKit.Generators.Messaging; - namespace MyApp; + public sealed record PartnerOrder(string Id); + public sealed record CommerceOrder(string Id); + + [GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder), {{invalidConfiguration}})] + public static partial class PartnerCommerceBridge; + """)) + .Then("the expected diagnostic is reported", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == expected)) + .AssertPassed(); + + [Scenario("Generates messaging bridge defaults and type shapes")] + [Fact] + public Task Generates_Messaging_Bridge_Defaults_And_Type_Shapes() + => Given("messaging bridge declarations using default names and different host shapes", () => Compile(""" + using PatternKit.Generators.Messaging; + + namespace Demo; + + public sealed record PartnerOrder(string Id); + public sealed record CommerceOrder(string Id); + + [GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder))] + internal abstract partial class AbstractBridge; + + [GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder), BridgeName = "tenant\"bridge")] + public sealed partial class SealedBridge; + + [GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder))] + internal partial struct StructBridge; + """)) + .Then("generated sources preserve host shape and configured names", result => + { + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Equal(3, result.GeneratedSources.Count); + + var combined = string.Join("\n", result.GeneratedSources); + ScenarioExpect.Contains("internal abstract partial class AbstractBridge", combined); + ScenarioExpect.Contains("Create()", combined); + ScenarioExpect.Contains("MessagingBridge.Create(@\"messaging-bridge\")", combined); + ScenarioExpect.Contains("public sealed partial class SealedBridge", combined); + ScenarioExpect.Contains("MessagingBridge.Create(@\"tenant\"\"bridge\")", combined); + ScenarioExpect.Contains("internal partial struct StructBridge", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Generates nested messaging bridge host wrappers")] + [Fact] + public Task Generates_Nested_Messaging_Bridge_Host_Wrappers() + => Given("nested messaging bridge declarations with non-public accessibility", () => Compile(""" + using PatternKit.Generators.Messaging; + + namespace Demo; public sealed record PartnerOrder(string Id); public sealed record CommerceOrder(string Id); - {{declaration}} - """) - .When("the generator runs", source => + public partial class BridgeContainer { - var comp = CreateCompilation(source, nameof(Reports_MessagingBridge_Diagnostics) + expected); - _ = RoslynTestHelpers.Run(comp, new MessagingBridgeGenerator(), out var run, out _); - return run.Diagnostics.Select(static d => d.Id).ToArray(); - }) - .Then("the expected diagnostic is reported", ids => - ScenarioExpect.Contains(expected, ids)) - .AssertPassed(); - - private static Compilation CreateCompilation(string source, string assemblyName) - => RoslynTestHelpers.CreateCompilation(source, assemblyName, + private partial class PrivateHost + { + [GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder))] + protected partial class ProtectedBridge; + + [GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder))] + private protected partial class PrivateProtectedBridge; + + [GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder))] + protected internal partial class ProtectedInternalBridge; + } + } + """)) + .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); + ScenarioExpect.Contains("public partial class BridgeContainer", combined); + ScenarioExpect.Contains("private partial class PrivateHost", combined); + ScenarioExpect.Contains("protected partial class ProtectedBridge", combined); + ScenarioExpect.Contains("private protected partial class PrivateProtectedBridge", combined); + ScenarioExpect.Contains("protected internal partial class ProtectedInternalBridge", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Skips malformed messaging bridge type arguments")] + [Theory] + [InlineData("null!", "typeof(CommerceOrder)")] + [InlineData("typeof(PartnerOrder)", "null!")] + public Task Skips_Malformed_Messaging_Bridge_Type_Arguments(string inboundType, string outboundType) + => Given("a messaging bridge declaration with a null message type", () => Compile($$""" + using PatternKit.Generators.Messaging; + + public sealed record PartnerOrder(string Id); + public sealed record CommerceOrder(string Id); + + [GenerateMessagingBridge({{inboundType}}, {{outboundType}})] + public static partial class PartnerCommerceBridge; + """)) + .Then("no source is generated", result => + ScenarioExpect.Empty(result.GeneratedSources)) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation(source, "MessagingBridgeGeneratorTests", extra: [ MetadataReference.CreateFromFile(typeof(GenerateMessagingBridgeAttribute).Assembly.Location), - MetadataReference.CreateFromFile(typeof(global::PatternKit.Messaging.Bridges.MessagingBridge<,>).Assembly.Location) + MetadataReference.CreateFromFile(typeof(PatternKit.Messaging.Bridges.MessagingBridge<,>).Assembly.Location) ]); + _ = RoslynTestHelpers.Run(compilation, new MessagingBridgeGenerator(), 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); }