diff --git a/src/PatternKit.Generators/EventNotification/EventNotificationGenerator.cs b/src/PatternKit.Generators/EventNotification/EventNotificationGenerator.cs index 2a74cc6a..d6256b1d 100644 --- a/src/PatternKit.Generators/EventNotification/EventNotificationGenerator.cs +++ b/src/PatternKit.Generators/EventNotification/EventNotificationGenerator.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using System.Text; using Microsoft.CodeAnalysis; @@ -172,6 +173,59 @@ 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.EnterpriseIntegration.EventNotification.EventNotification<") + .Append(eventTypeName).Append(", ").Append(keyTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.Append(memberIndent).AppendLine("{"); + sb.Append(bodyIndent).Append("return global::PatternKit.EnterpriseIntegration.EventNotification.EventNotification<") + .Append(eventTypeName).Append(", ").Append(keyTypeName).Append(">.Create(\"").Append(Escape(notificationName)).AppendLine("\")"); + if (!string.IsNullOrWhiteSpace(ruleName)) + sb.Append(bodyIndent).Append(" .When(").Append(ruleName).AppendLine(")"); + sb.Append(bodyIndent).Append(" .WithKey(").Append(keySelectorName).AppendLine(")"); + if (!string.IsNullOrWhiteSpace(correlationSelectorName)) + sb.Append(bodyIndent).Append(" .WithCorrelation(").Append(correlationSelectorName).AppendLine(")"); + foreach (var item in metadata) + sb.Append(bodyIndent).Append(" .WithMetadata(\"").Append(Escape(item.Name)).Append("\", ").Append(item.Method.Name).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 "); @@ -179,24 +233,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.EnterpriseIntegration.EventNotification.EventNotification<") - .Append(eventTypeName).Append(", ").Append(keyTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); - sb.AppendLine(" {"); - sb.Append(" return global::PatternKit.EnterpriseIntegration.EventNotification.EventNotification<") - .Append(eventTypeName).Append(", ").Append(keyTypeName).Append(">.Create(\"").Append(Escape(notificationName)).AppendLine("\")"); - if (!string.IsNullOrWhiteSpace(ruleName)) - sb.Append(" .When(").Append(ruleName).AppendLine(")"); - sb.Append(" .WithKey(").Append(keySelectorName).AppendLine(")"); - if (!string.IsNullOrWhiteSpace(correlationSelectorName)) - sb.Append(" .WithCorrelation(").Append(correlationSelectorName).AppendLine(")"); - foreach (var item in metadata) - sb.Append(" .WithMetadata(\"").Append(Escape(item.Name)).Append("\", ").Append(item.Method.Name).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? GetNamedString(AttributeData attribute, string name) diff --git a/src/PatternKit.Generators/Messaging/MessageEnvelopeGenerator.cs b/src/PatternKit.Generators/Messaging/MessageEnvelopeGenerator.cs index dba231f2..77c4da35 100644 --- a/src/PatternKit.Generators/Messaging/MessageEnvelopeGenerator.cs +++ b/src/PatternKit.Generators/Messaging/MessageEnvelopeGenerator.cs @@ -178,31 +178,61 @@ private static string GenerateSource( sb.AppendLine(); } - sb.Append("partial ").Append(GetKind(type)).Append(' ').Append(type.Name).AppendLine(); - sb.AppendLine("{"); - sb.Append(" public static global::PatternKit.Messaging.Message<").Append(payload).Append("> ").Append(config.FactoryName).Append('(').Append(payload).Append(" payload"); + 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.Messaging.Message<").Append(payload).Append("> ").Append(config.FactoryName).Append('(').Append(payload).Append(" payload"); foreach (var header in headers) sb.Append(", ").Append(header.ValueType).Append(' ').Append(header.ParameterName); sb.AppendLine(")"); - sb.Append(" => global::PatternKit.Messaging.Message<").Append(payload).AppendLine(">.Create(payload)"); + sb.Append(bodyIndent).Append("=> global::PatternKit.Messaging.Message<").Append(payload).AppendLine(">.Create(payload)"); foreach (var header in headers) - sb.Append(" .WithHeader(\"").Append(Escape(header.Name)).Append("\", ").Append(header.ParameterName).AppendLine(")"); - sb.AppendLine(" ;"); + sb.Append(bodyIndent).Append(" .WithHeader(\"").Append(Escape(header.Name)).Append("\", ").Append(header.ParameterName).AppendLine(")"); + sb.Append(bodyIndent).AppendLine(" ;"); sb.AppendLine(); - sb.Append(" public static global::PatternKit.Messaging.MessageContext ").Append(config.ContextFactoryName) + sb.Append(memberIndent).Append("public static global::PatternKit.Messaging.MessageContext ").Append(config.ContextFactoryName) .Append("(global::PatternKit.Messaging.Message<").Append(payload).Append("> message, global::System.Threading.CancellationToken cancellationToken = default)"); sb.AppendLine(); - sb.AppendLine(" {"); - sb.AppendLine(" if (message is null)"); - sb.AppendLine(" throw new global::System.ArgumentNullException(nameof(message));"); + sb.Append(memberIndent).AppendLine("{"); + sb.Append(bodyIndent).AppendLine("if (message is null)"); + sb.Append(bodyIndent).AppendLine(" throw new global::System.ArgumentNullException(nameof(message));"); sb.AppendLine(); - sb.AppendLine(" return global::PatternKit.Messaging.MessageContext.From(message, cancellationToken);"); - sb.AppendLine(" }"); - sb.AppendLine("}"); + sb.Append(bodyIndent).AppendLine("return global::PatternKit.Messaging.MessageContext.From(message, cancellationToken);"); + 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 string ToParameterName(string headerName) { var sb = new StringBuilder(); @@ -231,8 +261,30 @@ private static bool IsValidIdentifier(string value) && SyntaxFacts.IsValidIdentifier(value) && SyntaxFacts.GetKeywordKind(value) == SyntaxKind.None; - private static string GetKind(INamedTypeSymbol type) - => type.TypeKind == TypeKind.Struct ? "struct" : "class"; + 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 "); + 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); + } + + 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" + }; private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); diff --git a/test/PatternKit.Generators.Tests/EventNotificationGeneratorTests.cs b/test/PatternKit.Generators.Tests/EventNotificationGeneratorTests.cs index 79563cf2..ebec15cf 100644 --- a/test/PatternKit.Generators.Tests/EventNotificationGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/EventNotificationGeneratorTests.cs @@ -45,52 +45,142 @@ public static partial class OrderAcceptedNotification .AssertPassed(); [Scenario("Reports diagnostics for invalid event notification declarations")] + [Theory] + [InlineData("public static class NotificationHost { [EventNotificationKey] private static string Key(OrderAccepted evt) => evt.OrderId; }", "PKEN001")] + [InlineData("public static partial class NotificationHost;", "PKEN002")] + [InlineData("public static partial class NotificationHost { [EventNotificationKey] private static string One(OrderAccepted evt) => evt.OrderId; [EventNotificationKey] private static string Two(OrderAccepted evt) => evt.OrderId; }", "PKEN002")] + [InlineData("public partial class NotificationHost { [EventNotificationKey] private string Key(OrderAccepted evt) => evt.OrderId; }", "PKEN003")] + [InlineData("public static partial class NotificationHost { [EventNotificationKey] private static int Key(OrderAccepted evt) => evt.OrderId.Length; }", "PKEN003")] + [InlineData("public static partial class NotificationHost { [EventNotificationKey] private static string Key() => string.Empty; }", "PKEN003")] + [InlineData("public static partial class NotificationHost { [EventNotificationKey] private static string Key(string evt) => evt; }", "PKEN003")] + [InlineData("public static partial class NotificationHost { [EventNotificationKey] private static string Key(OrderAccepted evt) => evt.OrderId; [EventNotificationCorrelation] private static int Correlation(OrderAccepted evt) => 1; }", "PKEN003")] + [InlineData("public static partial class NotificationHost { [EventNotificationKey] private static string Key(OrderAccepted evt) => evt.OrderId; [EventNotificationRule] private static string Rule(OrderAccepted evt) => evt.OrderId; }", "PKEN003")] + [InlineData("public static partial class NotificationHost { [EventNotificationKey] private static string Key(OrderAccepted evt) => evt.OrderId; [EventNotificationMetadata(\"source\")] private static int Source(OrderAccepted evt) => 1; }", "PKEN003")] + [InlineData("public static partial class NotificationHost { [EventNotificationKey] private static string Key(OrderAccepted evt) => evt.OrderId; [EventNotificationMetadata(\"source\")] private static string Source(OrderAccepted evt) => evt.Source; [EventNotificationMetadata(\"SOURCE\")] private static string OtherSource(OrderAccepted evt) => evt.Source; }", "PKEN004")] + public Task Reports_Diagnostics_For_Invalid_Event_Notification_Declarations(string declaration, string diagnosticId) + => Given("an invalid event notification declaration", () => Compile($$""" + using PatternKit.Generators.EventNotification; + public sealed record OrderAccepted(string OrderId, string CorrelationId, string Source, bool NotifySubscribers); + [GenerateEventNotification(typeof(OrderAccepted), typeof(string))] + {{declaration}} + """)) + .Then("diagnostics identify invalid declarations", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == diagnosticId)) + .AssertPassed(); + + [Scenario("Generates event notification defaults and host shapes")] [Fact] - public Task Reports_Diagnostics_For_Invalid_Event_Notification_Declarations() - => Given("invalid event notification declarations", () => new[] + public Task Generates_Event_Notification_Defaults_And_Host_Shapes() + => Given("event notification declarations with default names and different host shapes", () => Compile(""" + using PatternKit.Generators.EventNotification; + namespace Demo; + public sealed record OrderAccepted(string OrderId, string CorrelationId, string Source, bool NotifySubscribers); + + [GenerateEventNotification(typeof(OrderAccepted), typeof(string))] + internal abstract partial class AbstractNotification + { + [EventNotificationKey] + private static string Key(OrderAccepted evt) => evt.OrderId; + } + + [GenerateEventNotification(typeof(OrderAccepted), typeof(string), NotificationName = "tenant\\\"notification")] + public sealed partial class SealedNotification + { + [EventNotificationKey] + private static string Key(OrderAccepted evt) => evt.OrderId; + } + + [GenerateEventNotification(typeof(OrderAccepted), typeof(string))] + internal partial struct StructNotification + { + [EventNotificationKey] + private static string Key(OrderAccepted evt) => evt.OrderId; + } + """)) + .Then("generated sources preserve host shape and configured names", result => { - Compile(""" - using PatternKit.Generators.EventNotification; - [GenerateEventNotification(typeof(string), typeof(string))] - public static class NotificationHost; - """), - Compile(""" - using PatternKit.Generators.EventNotification; - [GenerateEventNotification(typeof(string), typeof(string))] - public static partial class NotificationHost; - """), - Compile(""" - using PatternKit.Generators.EventNotification; - [GenerateEventNotification(typeof(string), typeof(string))] - public static partial class NotificationHost - { - [EventNotificationKey] - private static int Key(string value) => value.Length; - } - """), - Compile(""" - using PatternKit.Generators.EventNotification; - [GenerateEventNotification(typeof(string), typeof(string))] - public static partial class NotificationHost + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Equal(3, result.GeneratedSources.Count); + + var combined = string.Join("\n", result.GeneratedSources); + ScenarioExpect.Contains("internal abstract partial class AbstractNotification", combined); + ScenarioExpect.Contains("public sealed partial class SealedNotification", combined); + ScenarioExpect.Contains("internal partial struct StructNotification", combined); + ScenarioExpect.Contains("Create(\"event-notification\")", combined); + ScenarioExpect.Contains("Create(\"tenant\\\\\\\"notification\")", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Generates nested event notification host wrappers")] + [Fact] + public Task Generates_Nested_Event_Notification_Host_Wrappers() + => Given("nested event notification declarations", () => Compile(""" + using PatternKit.Generators.EventNotification; + namespace Demo; + public sealed record OrderAccepted(string OrderId, string CorrelationId, string Source, bool NotifySubscribers); + + public partial class NotificationContainer + { + private partial class PrivateHost { - [EventNotificationKey] - private static string Key(string value) => value; - [EventNotificationMetadata("source")] - private static string Source(string value) => value; - [EventNotificationMetadata("SOURCE")] - private static string OtherSource(string value) => value; + [GenerateEventNotification(typeof(OrderAccepted), typeof(string))] + protected partial class ProtectedNotification + { + [EventNotificationKey] + private static string Key(OrderAccepted evt) => evt.OrderId; + } + + [GenerateEventNotification(typeof(OrderAccepted), typeof(string))] + private protected partial class PrivateProtectedNotification + { + [EventNotificationKey] + private static string Key(OrderAccepted evt) => evt.OrderId; + } + + [GenerateEventNotification(typeof(OrderAccepted), typeof(string))] + protected internal partial class ProtectedInternalNotification + { + [EventNotificationKey] + private static string Key(OrderAccepted evt) => evt.OrderId; + } } - """) - }) - .Then("diagnostics identify invalid declarations", results => + } + """)) + .Then("generated sources preserve containing partial type wrappers", result => { - ScenarioExpect.Contains(results[0].Diagnostics, diagnostic => diagnostic.Id == "PKEN001"); - ScenarioExpect.Contains(results[1].Diagnostics, diagnostic => diagnostic.Id == "PKEN002"); - ScenarioExpect.Contains(results[2].Diagnostics, diagnostic => diagnostic.Id == "PKEN003"); - ScenarioExpect.Contains(results[3].Diagnostics, diagnostic => diagnostic.Id == "PKEN004"); + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Equal(3, result.GeneratedSources.Count); + + var combined = string.Join("\n", result.GeneratedSources); + ScenarioExpect.Contains("public partial class NotificationContainer", combined); + ScenarioExpect.Contains("private partial class PrivateHost", combined); + ScenarioExpect.Contains("protected partial class ProtectedNotification", combined); + ScenarioExpect.Contains("private protected partial class PrivateProtectedNotification", combined); + ScenarioExpect.Contains("protected internal partial class ProtectedInternalNotification", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); }) .AssertPassed(); + [Scenario("Skips malformed event notification type arguments")] + [Theory] + [InlineData("null!", "typeof(string)")] + [InlineData("typeof(OrderAccepted)", "null!")] + public Task Skips_Malformed_Event_Notification_Type_Arguments(string eventType, string keyType) + => Given("an event notification declaration with a null type argument", () => Compile($$""" + using PatternKit.Generators.EventNotification; + public sealed record OrderAccepted(string OrderId, string CorrelationId, string Source, bool NotifySubscribers); + [GenerateEventNotification({{eventType}}, {{keyType}})] + public static partial class OrderAcceptedNotification + { + [EventNotificationKey] + private static string Key(OrderAccepted evt) => evt.OrderId; + } + """)) + .Then("no source is generated", result => + ScenarioExpect.Empty(result.GeneratedSources)) + .AssertPassed(); + private static GeneratorResult Compile(string source) { var compilation = RoslynTestHelpers.CreateCompilation( diff --git a/test/PatternKit.Generators.Tests/MessageEnvelopeGeneratorTests.cs b/test/PatternKit.Generators.Tests/MessageEnvelopeGeneratorTests.cs index 4cfcac4d..d17e4625 100644 --- a/test/PatternKit.Generators.Tests/MessageEnvelopeGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/MessageEnvelopeGeneratorTests.cs @@ -2,16 +2,18 @@ using Microsoft.CodeAnalysis.CSharp; using PatternKit.Generators.Messaging; using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; namespace PatternKit.Generators.Tests; -public sealed class MessageEnvelopeGeneratorTests +[Feature("Message Envelope generator")] +public sealed partial class MessageEnvelopeGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) { [Scenario("Generates typed envelope and context factories")] [Fact] - public void GeneratesTypedEnvelopeAndContextFactories() - { - var source = """ + public Task Generates_Typed_Envelope_And_Context_Factories() + => Given("a typed message envelope declaration", () => Compile(""" using PatternKit.Generators.Messaging; using PatternKit.Messaging; @@ -34,118 +36,141 @@ public static string Run() return $"{message.Payload.OrderId}:{context.Headers.CorrelationId}:{context.Headers.GetString("tenant-id")}"; } } - """; - - var comp = CreateCompilation(source, nameof(GeneratesTypedEnvelopeAndContextFactories)); - var gen = new MessageEnvelopeGenerator(); - _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); - - ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); - var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources)); - ScenarioExpect.Equal("OrderAcceptedEnvelope.MessageEnvelope.g.cs", generated.HintName); - var text = generated.SourceText.ToString(); - ScenarioExpect.Contains("CreateAccepted(global::MyApp.OrderAccepted payload, string messageId, string correlationId, string tenantId)", text); - ScenarioExpect.Contains(".WithHeader(\"tenant-id\", tenantId)", text); - ScenarioExpect.Contains("ContextFor(global::PatternKit.Messaging.Message message", text); - - var emit = updated.Emit(Stream.Null); - ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); - } - - [Scenario("Reports diagnostic for non-partial envelope contract")] - [Fact] - public void ReportsDiagnosticForNonPartialEnvelopeContract() - { - var source = """ + """)) + .Then("generated source creates the configured envelope", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var generated = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Equal("OrderAcceptedEnvelope.MessageEnvelope.g.cs", generated.HintName); + ScenarioExpect.Contains("public static partial class OrderAcceptedEnvelope", generated.Source); + ScenarioExpect.Contains("CreateAccepted(global::MyApp.OrderAccepted payload, string messageId, string correlationId, string tenantId)", generated.Source); + ScenarioExpect.Contains(".WithHeader(\"tenant-id\", tenantId)", generated.Source); + ScenarioExpect.Contains("ContextFor(global::PatternKit.Messaging.Message message", generated.Source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid message envelope declarations")] + [Theory] + [InlineData("[GenerateMessageEnvelope(typeof(OrderAccepted))] [MessageEnvelopeHeader(\"message-id\", typeof(string))] public static class OrderAcceptedEnvelope;", "PKME001")] + [InlineData("[GenerateMessageEnvelope(typeof(OrderAccepted))] public static partial class OrderAcceptedEnvelope;", "PKME002")] + [InlineData("[GenerateMessageEnvelope(typeof(OrderAccepted))] [MessageEnvelopeHeader(\"\", typeof(string))] public static partial class OrderAcceptedEnvelope;", "PKME003")] + [InlineData("[GenerateMessageEnvelope(typeof(OrderAccepted))] [MessageEnvelopeHeader(\"tenant-id\", null!)] public static partial class OrderAcceptedEnvelope;", "PKME003")] + [InlineData("[GenerateMessageEnvelope(typeof(OrderAccepted))] [MessageEnvelopeHeader(\"tenant-id\", typeof(string), ParameterName = \"class\")] public static partial class OrderAcceptedEnvelope;", "PKME003")] + [InlineData("[GenerateMessageEnvelope(typeof(OrderAccepted))] [MessageEnvelopeHeader(\"tenant-id\", typeof(string), ParameterName = \"tenantId\")] [MessageEnvelopeHeader(\"Tenant-Id\", typeof(string), ParameterName = \"tenant\")] public static partial class OrderAcceptedEnvelope;", "PKME004")] + [InlineData("[GenerateMessageEnvelope(typeof(OrderAccepted))] [MessageEnvelopeHeader(\"tenant-id\", typeof(string), ParameterName = \"tenantId\")] [MessageEnvelopeHeader(\"customer-id\", typeof(string), ParameterName = \"tenantId\")] public static partial class OrderAcceptedEnvelope;", "PKME004")] + public Task Reports_Diagnostics_For_Invalid_Message_Envelope_Declarations(string declaration, string diagnosticId) + => Given("an invalid message envelope declaration", () => Compile($$""" using PatternKit.Generators.Messaging; - - namespace MyApp; - public sealed record OrderAccepted(string OrderId); + {{declaration}} + """)) + .Then("the expected diagnostic is reported", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == diagnosticId)) + .AssertPassed(); - [GenerateMessageEnvelope(typeof(OrderAccepted))] - [MessageEnvelopeHeader("message-id", typeof(string))] - public static class OrderAcceptedEnvelope; - """; - - var comp = CreateCompilation(source, nameof(ReportsDiagnosticForNonPartialEnvelopeContract)); - var gen = new MessageEnvelopeGenerator(); - _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); - - var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); - ScenarioExpect.Equal("PKME001", diagnostic.Id); - } - - [Scenario("Reports diagnostic for missing envelope headers")] + [Scenario("Generates message envelope defaults and host shapes")] [Fact] - public void ReportsDiagnosticForMissingEnvelopeHeaders() - { - var source = """ + public Task Generates_Message_Envelope_Defaults_And_Host_Shapes() + => Given("message envelope declarations with default names and different host shapes", () => Compile(""" using PatternKit.Generators.Messaging; - namespace MyApp; - public sealed record OrderAccepted(string OrderId); [GenerateMessageEnvelope(typeof(OrderAccepted))] - public static partial class OrderAcceptedEnvelope; - """; - - var comp = CreateCompilation(source, nameof(ReportsDiagnosticForMissingEnvelopeHeaders)); - var gen = new MessageEnvelopeGenerator(); - _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + [MessageEnvelopeHeader("message-id", typeof(string))] + internal abstract partial class AbstractEnvelope; - var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); - ScenarioExpect.Equal("PKME002", diagnostic.Id); - } + [GenerateMessageEnvelope(typeof(OrderAccepted))] + [MessageEnvelopeHeader("tenant-id", typeof(string), ParameterName = "tenantId")] + public sealed partial class SealedEnvelope; - [Scenario("Reports diagnostic for invalid generated parameter name")] + [GenerateMessageEnvelope(typeof(OrderAccepted))] + [MessageEnvelopeHeader("customer-id", typeof(string), ParameterName = "customerId")] + internal partial struct StructEnvelope; + """)) + .Then("generated sources preserve host shape and default factories", 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 AbstractEnvelope", combined); + ScenarioExpect.Contains("public sealed partial class SealedEnvelope", combined); + ScenarioExpect.Contains("internal partial struct StructEnvelope", combined); + ScenarioExpect.Contains("Create(global::MyApp.OrderAccepted payload", combined); + ScenarioExpect.Contains("CreateContext(global::PatternKit.Messaging.Message message", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Generates nested message envelope host wrappers")] [Fact] - public void ReportsDiagnosticForInvalidGeneratedParameterName() - { - var source = """ + public Task Generates_Nested_Message_Envelope_Host_Wrappers() + => Given("nested message envelope declarations", () => Compile(""" using PatternKit.Generators.Messaging; - namespace MyApp; - public sealed record OrderAccepted(string OrderId); - [GenerateMessageEnvelope(typeof(OrderAccepted))] - [MessageEnvelopeHeader("tenant-id", typeof(string), ParameterName = "class")] - public static partial class OrderAcceptedEnvelope; - """; - - var comp = CreateCompilation(source, nameof(ReportsDiagnosticForInvalidGeneratedParameterName)); - var gen = new MessageEnvelopeGenerator(); - _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + public partial class EnvelopeContainer + { + private partial class PrivateHost + { + [GenerateMessageEnvelope(typeof(OrderAccepted))] + [MessageEnvelopeHeader("protected-id", typeof(string), ParameterName = "protectedId")] + protected partial class ProtectedEnvelope; - var diagnostics = run.Results.SelectMany(result => result.Diagnostics).Select(static diagnostic => diagnostic.Id); - ScenarioExpect.Contains("PKME003", diagnostics); - } + [GenerateMessageEnvelope(typeof(OrderAccepted))] + [MessageEnvelopeHeader("private-protected-id", typeof(string), ParameterName = "privateProtectedId")] + private protected partial class PrivateProtectedEnvelope; - [Scenario("Reports diagnostic for duplicate header names")] + [GenerateMessageEnvelope(typeof(OrderAccepted))] + [MessageEnvelopeHeader("protected-internal-id", typeof(string), ParameterName = "protectedInternalId")] + protected internal partial class ProtectedInternalEnvelope; + } + } + """)) + .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 EnvelopeContainer", combined); + ScenarioExpect.Contains("private partial class PrivateHost", combined); + ScenarioExpect.Contains("protected partial class ProtectedEnvelope", combined); + ScenarioExpect.Contains("private protected partial class PrivateProtectedEnvelope", combined); + ScenarioExpect.Contains("protected internal partial class ProtectedInternalEnvelope", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Skips malformed message envelope type arguments")] [Fact] - public void ReportsDiagnosticForDuplicateHeaderNames() - { - var source = """ + public Task Skips_Malformed_Message_Envelope_Type_Arguments() + => Given("a message envelope declaration with a null type argument", () => Compile(""" using PatternKit.Generators.Messaging; - - namespace MyApp; - - public sealed record OrderAccepted(string OrderId); - - [GenerateMessageEnvelope(typeof(OrderAccepted))] - [MessageEnvelopeHeader("tenant-id", typeof(string), ParameterName = "tenantId")] - [MessageEnvelopeHeader("Tenant-Id", typeof(string), ParameterName = "tenant")] + [GenerateMessageEnvelope(null!)] + [MessageEnvelopeHeader("message-id", typeof(string))] public static partial class OrderAcceptedEnvelope; - """; - - var comp = CreateCompilation(source, nameof(ReportsDiagnosticForDuplicateHeaderNames)); - var gen = new MessageEnvelopeGenerator(); - _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + """)) + .Then("no source is generated", result => + ScenarioExpect.Empty(result.GeneratedSources)) + .AssertPassed(); - var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); - ScenarioExpect.Equal("PKME004", diagnostic.Id); + private static GeneratorResult Compile(string source) + { + var compilation = CreateCompilation(source, "MessageEnvelopeGeneratorTests"); + _ = RoslynTestHelpers.Run(compilation, new MessageEnvelopeGenerator(), 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) @@ -153,4 +178,12 @@ private static CSharpCompilation CreateCompilation(string source, string assembl source, assemblyName, extra: MetadataReference.CreateFromFile(typeof(PatternKit.Messaging.Message<>).Assembly.Location)); + + private sealed record GeneratorResult( + IReadOnlyList Diagnostics, + IReadOnlyList GeneratedSources, + bool EmitSuccess, + IReadOnlyList EmitDiagnostics); + + private sealed record GeneratedSource(string HintName, string Source); }