From b38ee70375cf8bee683669f1628b2785a2a5bfb7 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 29 May 2026 18:55:55 -0500 Subject: [PATCH] test(generators): cover domain events and sidecar --- .../DomainEventDispatcherGenerator.cs | 68 ++++-- .../Sidecar/SidecarGenerator.cs | 69 ++++-- .../DomainEventDispatcherGeneratorTests.cs | 147 ++++++++++++- .../SidecarGeneratorTests.cs | 196 ++++++++++++++---- 4 files changed, 401 insertions(+), 79 deletions(-) diff --git a/src/PatternKit.Generators/DomainEvents/DomainEventDispatcherGenerator.cs b/src/PatternKit.Generators/DomainEvents/DomainEventDispatcherGenerator.cs index 37433d8c..6c16acfc 100644 --- a/src/PatternKit.Generators/DomainEvents/DomainEventDispatcherGenerator.cs +++ b/src/PatternKit.Generators/DomainEvents/DomainEventDispatcherGenerator.cs @@ -132,32 +132,68 @@ private static string GenerateSource( 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.Application.DomainEvents.DomainEventDispatcher<") + 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.Application.DomainEvents.DomainEventDispatcher<") .Append(eventBaseName).Append("> ").Append(factoryName).AppendLine("()"); - sb.AppendLine(" {"); - sb.Append(" var builder = global::PatternKit.Application.DomainEvents.DomainEventDispatcher<") + sb.Append(memberIndent).AppendLine("{"); + sb.Append(bodyIndent).Append("var builder = global::PatternKit.Application.DomainEvents.DomainEventDispatcher<") .Append(eventBaseName).Append(">.Create(\"").Append(Escape(dispatcherName)).AppendLine("\");"); foreach (var handler in handlers) { var eventTypeName = handler.EventType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - sb.Append(" builder.Handle<").Append(eventTypeName).Append(">(").Append(handler.Method.Name).AppendLine(");"); + sb.Append(bodyIndent).Append("builder.Handle<").Append(eventTypeName).Append(">(").Append(handler.Method.Name).AppendLine(");"); + } + + sb.Append(bodyIndent).AppendLine("return builder.Build();"); + sb.AppendLine(memberIndent + "}"); + sb.AppendLine(indent + "}"); + for (var i = containingTypes.Length - 1; i >= 0; i--) + { + sb.AppendLine(new string(' ', i * 4) + "}"); } - sb.AppendLine(" return builder.Build();"); - sb.AppendLine(" }"); - sb.AppendLine("}"); 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 "); + 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 bool IsHandler(IMethodSymbol method, INamedTypeSymbol eventType, INamedTypeSymbol eventBaseType) => method.IsStatic && !method.IsGenericMethod diff --git a/src/PatternKit.Generators/Sidecar/SidecarGenerator.cs b/src/PatternKit.Generators/Sidecar/SidecarGenerator.cs index 89ba63e4..690864bd 100644 --- a/src/PatternKit.Generators/Sidecar/SidecarGenerator.cs +++ b/src/PatternKit.Generators/Sidecar/SidecarGenerator.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using System.Text; using Microsoft.CodeAnalysis; @@ -171,6 +172,57 @@ private static string GenerateSource(INamedTypeSymbol type, INamedTypeSymbol req 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.Sidecar.Sidecar<") + .Append(requestTypeName).Append(", ").Append(responseTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.Append(memberIndent).AppendLine("{"); + sb.Append(bodyIndent).Append("return global::PatternKit.Cloud.Sidecar.Sidecar<") + .Append(requestTypeName).Append(", ").Append(responseTypeName).Append(">.Create(\"").Append(Escape(sidecarName)).AppendLine("\")"); + foreach (var step in before) + sb.Append(bodyIndent).Append(" .Before(\"").Append(Escape(step.Name)).Append("\", ").Append(step.Method.Name).AppendLine(")"); + foreach (var step in after) + sb.Append(bodyIndent).Append(" .After(\"").Append(Escape(step.Name)).Append("\", ").Append(step.Method.Name).AppendLine(")"); + sb.Append(bodyIndent).Append(" .Handle(").Append(handlerName).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 "); @@ -178,22 +230,7 @@ private static string GenerateSource(INamedTypeSymbol type, INamedTypeSymbol req 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.Sidecar.Sidecar<") - .Append(requestTypeName).Append(", ").Append(responseTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); - sb.AppendLine(" {"); - sb.Append(" return global::PatternKit.Cloud.Sidecar.Sidecar<") - .Append(requestTypeName).Append(", ").Append(responseTypeName).Append(">.Create(\"").Append(Escape(sidecarName)).AppendLine("\")"); - foreach (var step in before) - sb.Append(" .Before(\"").Append(Escape(step.Name)).Append("\", ").Append(step.Method.Name).AppendLine(")"); - foreach (var step in after) - sb.Append(" .After(\"").Append(Escape(step.Name)).Append("\", ").Append(step.Method.Name).AppendLine(")"); - sb.Append(" .Handle(").Append(handlerName).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/test/PatternKit.Generators.Tests/DomainEventDispatcherGeneratorTests.cs b/test/PatternKit.Generators.Tests/DomainEventDispatcherGeneratorTests.cs index efec2d98..85975890 100644 --- a/test/PatternKit.Generators.Tests/DomainEventDispatcherGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/DomainEventDispatcherGeneratorTests.cs @@ -40,6 +40,7 @@ public static partial class OrderEventHandlers ScenarioExpect.Contains("Handle(Project)", source); ScenarioExpect.Contains("Handle(Audit)", source); ScenarioExpect.True(source.IndexOf("Project", StringComparison.Ordinal) < source.IndexOf("Audit", StringComparison.Ordinal)); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); }) .AssertPassed(); @@ -47,7 +48,12 @@ public static partial class OrderEventHandlers [Theory] [InlineData("public static class OrderEventHandlers { [DomainEventHandler(typeof(OrderPlaced), 1)] private static ValueTask Handle(OrderPlaced domainEvent, CancellationToken cancellationToken) => ValueTask.CompletedTask; }", "PKDE001")] [InlineData("public static partial class OrderEventHandlers;", "PKDE002")] - [InlineData("public static partial class OrderEventHandlers { [DomainEventHandler(typeof(OrderPlaced), 1)] private static string Handle(OrderPlaced domainEvent) => domainEvent.OrderId; }", "PKDE003")] + [InlineData("public partial class OrderEventHandlers { [DomainEventHandler(typeof(OrderPlaced), 1)] private ValueTask Handle(OrderPlaced domainEvent, CancellationToken cancellationToken) => ValueTask.CompletedTask; }", "PKDE003")] + [InlineData("public static partial class OrderEventHandlers { [DomainEventHandler(typeof(OrderPlaced), 1)] private static ValueTask Handle(OrderPlaced domainEvent, CancellationToken cancellationToken) => ValueTask.CompletedTask; }", "PKDE003")] + [InlineData("public static partial class OrderEventHandlers { [DomainEventHandler(typeof(OrderPlaced), 1)] private static string Handle(OrderPlaced domainEvent, CancellationToken cancellationToken) => domainEvent.OrderId; }", "PKDE003")] + [InlineData("public static partial class OrderEventHandlers { [DomainEventHandler(typeof(OrderPlaced), 1)] private static ValueTask Handle() => ValueTask.CompletedTask; }", "PKDE003")] + [InlineData("public static partial class OrderEventHandlers { [DomainEventHandler(typeof(OrderPlaced), 1)] private static ValueTask Handle(OrderEvent domainEvent, CancellationToken cancellationToken) => ValueTask.CompletedTask; }", "PKDE003")] + [InlineData("public static partial class OrderEventHandlers { [DomainEventHandler(typeof(OrderPlaced), 1)] private static ValueTask Handle(OrderPlaced domainEvent, string cancellationToken) => ValueTask.CompletedTask; }", "PKDE003")] [InlineData("public static partial class OrderEventHandlers { [DomainEventHandler(typeof(NotAnOrderEvent), 1)] private static ValueTask Handle(NotAnOrderEvent domainEvent, CancellationToken cancellationToken) => ValueTask.CompletedTask; }", "PKDE003")] [InlineData("public static partial class OrderEventHandlers { [DomainEventHandler(typeof(OrderPlaced), 1)] private static ValueTask One(OrderPlaced domainEvent, CancellationToken cancellationToken) => ValueTask.CompletedTask; [DomainEventHandler(typeof(OrderPlaced), 1)] private static ValueTask Two(OrderPlaced domainEvent, CancellationToken cancellationToken) => ValueTask.CompletedTask; }", "PKDE004")] public Task Generator_Reports_Invalid_Domain_Event_Declarations(string declaration, string diagnosticId) @@ -67,18 +73,151 @@ public sealed record NotAnOrderEvent(string Id); ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == diagnosticId)) .AssertPassed(); + [Scenario("Generator emits domain event defaults and host shapes")] + [Fact] + public Task Generator_Emits_Domain_Event_Defaults_And_Host_Shapes() + => Given("domain event dispatcher declarations with default names and different host shapes", () => Compile(""" + using System; + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Application.DomainEvents; + using PatternKit.Generators.DomainEvents; + namespace Demo; + public abstract record OrderEvent(Guid EventId, DateTimeOffset OccurredAt) : IDomainEvent; + public sealed record OrderPlaced(Guid EventId, DateTimeOffset OccurredAt, string OrderId) : OrderEvent(EventId, OccurredAt); + + [GenerateDomainEventDispatcher(typeof(OrderEvent))] + internal abstract partial class AbstractDispatcher + { + [DomainEventHandler(typeof(OrderPlaced), 1)] + private static ValueTask Handle(OrderPlaced domainEvent, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + + [GenerateDomainEventDispatcher(typeof(OrderEvent), DispatcherName = "tenant\\\"events")] + public sealed partial class SealedDispatcher + { + [DomainEventHandler(typeof(OrderPlaced), 1)] + private static ValueTask Handle(OrderPlaced domainEvent, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + + [GenerateDomainEventDispatcher(typeof(OrderEvent))] + internal partial struct StructDispatcher + { + [DomainEventHandler(typeof(OrderPlaced), 1)] + private static ValueTask Handle(OrderPlaced domainEvent, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + """)) + .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 AbstractDispatcher", combined); + ScenarioExpect.Contains("public sealed partial class SealedDispatcher", combined); + ScenarioExpect.Contains("internal partial struct StructDispatcher", combined); + ScenarioExpect.Contains("Create(\"AbstractDispatcher\")", combined); + ScenarioExpect.Contains("Create(\"tenant\\\\\\\"events\")", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Generator emits nested domain event host wrappers")] + [Fact] + public Task Generator_Emits_Nested_Domain_Event_Host_Wrappers() + => Given("nested domain event dispatcher declarations", () => Compile(""" + using System; + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Application.DomainEvents; + using PatternKit.Generators.DomainEvents; + namespace Demo; + public abstract record OrderEvent(Guid EventId, DateTimeOffset OccurredAt) : IDomainEvent; + public sealed record OrderPlaced(Guid EventId, DateTimeOffset OccurredAt, string OrderId) : OrderEvent(EventId, OccurredAt); + + public partial class DispatcherContainer + { + private partial class PrivateHost + { + [GenerateDomainEventDispatcher(typeof(OrderEvent))] + protected partial class ProtectedDispatcher + { + [DomainEventHandler(typeof(OrderPlaced), 1)] + private static ValueTask Handle(OrderPlaced domainEvent, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + + [GenerateDomainEventDispatcher(typeof(OrderEvent))] + private protected partial class PrivateProtectedDispatcher + { + [DomainEventHandler(typeof(OrderPlaced), 1)] + private static ValueTask Handle(OrderPlaced domainEvent, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + + [GenerateDomainEventDispatcher(typeof(OrderEvent))] + protected internal partial class ProtectedInternalDispatcher + { + [DomainEventHandler(typeof(OrderPlaced), 1)] + private static ValueTask Handle(OrderPlaced domainEvent, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + } + } + """)) + .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 DispatcherContainer", combined); + ScenarioExpect.Contains("private partial class PrivateHost", combined); + ScenarioExpect.Contains("protected partial class ProtectedDispatcher", combined); + ScenarioExpect.Contains("private protected partial class PrivateProtectedDispatcher", combined); + ScenarioExpect.Contains("protected internal partial class ProtectedInternalDispatcher", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Generator skips malformed domain event type arguments")] + [Fact] + public Task Generator_Skips_Malformed_Domain_Event_Type_Arguments() + => Given("a domain event dispatcher declaration with a null type argument", () => Compile(""" + using System; + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Application.DomainEvents; + using PatternKit.Generators.DomainEvents; + public abstract record OrderEvent(Guid EventId, DateTimeOffset OccurredAt) : IDomainEvent; + public sealed record OrderPlaced(Guid EventId, DateTimeOffset OccurredAt, string OrderId) : OrderEvent(EventId, OccurredAt); + [GenerateDomainEventDispatcher(null!)] + public static partial class OrderEventHandlers + { + [DomainEventHandler(typeof(OrderPlaced), 1)] + private static ValueTask Handle(OrderPlaced domainEvent, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + """)) + .Then("no source is generated", result => + ScenarioExpect.Empty(result.GeneratedSources)) + .AssertPassed(); + private static GeneratorResult Compile(string source) { var compilation = RoslynTestHelpers.CreateCompilation( source, "DomainEventDispatcherGeneratorTests", extra: MetadataReference.CreateFromFile(typeof(DomainEventDispatcher<>).Assembly.Location)); - _ = RoslynTestHelpers.Run(compilation, new DomainEventDispatcherGenerator(), out var run, out _); + _ = RoslynTestHelpers.Run(compilation, new DomainEventDispatcherGenerator(), 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()); + 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); + private sealed record GeneratorResult( + IReadOnlyList Diagnostics, + IReadOnlyList GeneratedSources, + bool EmitSuccess, + IReadOnlyList EmitDiagnostics); } diff --git a/test/PatternKit.Generators.Tests/SidecarGeneratorTests.cs b/test/PatternKit.Generators.Tests/SidecarGeneratorTests.cs index 6031254e..31a36f16 100644 --- a/test/PatternKit.Generators.Tests/SidecarGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/SidecarGeneratorTests.cs @@ -44,57 +44,167 @@ public static partial class OrderSidecars .AssertPassed(); [Scenario("Reports diagnostics for invalid Sidecar declarations")] + [Theory] + [InlineData("public static class SidecarHost { [SidecarBefore(\"trace\")] private static void Trace(SidecarContext ctx) { } [SidecarHandler] private static int Handle(SidecarContext ctx) => 1; }", "PKSC001")] + [InlineData("public static partial class SidecarHost;", "PKSC002")] + [InlineData("public static partial class SidecarHost { [SidecarHandler] private static int Handle(SidecarContext ctx) => 1; }", "PKSC002")] + [InlineData("public static partial class SidecarHost { [SidecarBefore(\"trace\")] private static void Trace(SidecarContext ctx) { } }", "PKSC002")] + [InlineData("public static partial class SidecarHost { [SidecarBefore(\"trace\")] private static void Trace(SidecarContext ctx) { } [SidecarHandler] private static int One(SidecarContext ctx) => 1; [SidecarHandler] private static int Two(SidecarContext ctx) => 2; }", "PKSC002")] + [InlineData("public static partial class SidecarHost { [SidecarBefore(\"trace\")] private static void Trace(SidecarContext ctx) { } [SidecarAfter(\"TRACE\")] private static void Metrics(SidecarContext ctx, int response) { } [SidecarHandler] private static int Handle(SidecarContext ctx) => 1; }", "PKSC004")] + [InlineData("public partial class SidecarHost { [SidecarBefore(\"trace\")] private void Trace(SidecarContext ctx) { } [SidecarHandler] private static int Handle(SidecarContext ctx) => 1; }", "PKSC003")] + [InlineData("public static partial class SidecarHost { [SidecarBefore(\"trace\")] private static string Trace(SidecarContext ctx) => ctx.Request; [SidecarHandler] private static int Handle(SidecarContext ctx) => 1; }", "PKSC003")] + [InlineData("public static partial class SidecarHost { [SidecarBefore(\"trace\")] private static void Trace() { } [SidecarHandler] private static int Handle(SidecarContext ctx) => 1; }", "PKSC003")] + [InlineData("public static partial class SidecarHost { [SidecarBefore(\"trace\")] private static void Trace(SidecarContext ctx) { } [SidecarHandler] private static int Handle(SidecarContext ctx) => 1; }", "PKSC003")] + [InlineData("public partial class SidecarHost { [SidecarAfter(\"metrics\")] private void Metrics(SidecarContext ctx, int response) { } [SidecarHandler] private static int Handle(SidecarContext ctx) => 1; }", "PKSC003")] + [InlineData("public static partial class SidecarHost { [SidecarAfter(\"metrics\")] private static string Metrics(SidecarContext ctx, int response) => string.Empty; [SidecarHandler] private static int Handle(SidecarContext ctx) => 1; }", "PKSC003")] + [InlineData("public static partial class SidecarHost { [SidecarAfter(\"metrics\")] private static void Metrics(SidecarContext ctx) { } [SidecarHandler] private static int Handle(SidecarContext ctx) => 1; }", "PKSC003")] + [InlineData("public static partial class SidecarHost { [SidecarAfter(\"metrics\")] private static void Metrics(SidecarContext ctx, string response) { } [SidecarHandler] private static int Handle(SidecarContext ctx) => 1; }", "PKSC003")] + [InlineData("public static partial class SidecarHost { [SidecarBefore(\"trace\")] private static void Trace(SidecarContext ctx) { } [SidecarHandler] private static string Handle(SidecarContext ctx) => string.Empty; }", "PKSC003")] + [InlineData("public static partial class SidecarHost { [SidecarBefore(\"trace\")] private static void Trace(SidecarContext ctx) { } [SidecarHandler] private static int Handle(string ctx) => 1; }", "PKSC003")] + public Task Reports_Diagnostics_For_Invalid_Sidecar_Declarations(string declaration, string diagnosticId) + => Given("an invalid Sidecar declaration", () => Compile($$""" + using PatternKit.Cloud.Sidecar; + using PatternKit.Generators.Sidecar; + [GenerateSidecar(typeof(string), typeof(int))] + {{declaration}} + """)) + .Then("diagnostics identify invalid declarations", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == diagnosticId)) + .AssertPassed(); + + [Scenario("Generates Sidecar defaults and host shapes")] [Fact] - public Task Reports_Diagnostics_For_Invalid_Sidecar_Declarations() - => Given("invalid Sidecar declarations", () => new[] + public Task Generates_Sidecar_Defaults_And_Host_Shapes() + => Given("Sidecar declarations with default names and different host shapes", () => Compile(""" + using PatternKit.Cloud.Sidecar; + using PatternKit.Generators.Sidecar; + namespace Demo; + public sealed record OrderRequest(string OrderId); + public sealed record OrderResponse(string Confirmation); + + [GenerateSidecar(typeof(OrderRequest), typeof(OrderResponse))] + internal abstract partial class AbstractSidecar + { + [SidecarBefore("trace")] + private static void Trace(SidecarContext ctx) { } + [SidecarHandler] + private static OrderResponse Handle(SidecarContext ctx) => new(ctx.Request.OrderId); + } + + [GenerateSidecar(typeof(OrderRequest), typeof(OrderResponse), SidecarName = "tenant\\\"sidecar")] + public sealed partial class SealedSidecar + { + [SidecarAfter("metrics")] + private static void Metrics(SidecarContext ctx, OrderResponse response) { } + [SidecarHandler] + private static OrderResponse Handle(SidecarContext ctx) => new(ctx.Request.OrderId); + } + + [GenerateSidecar(typeof(OrderRequest), typeof(OrderResponse))] + internal partial struct StructSidecar + { + [SidecarBefore("trace")] + private static void Trace(SidecarContext ctx) { } + [SidecarHandler] + private static OrderResponse Handle(SidecarContext ctx) => new(ctx.Request.OrderId); + } + """)) + .Then("generated sources preserve host shape and configured names", result => { - Compile(""" - using PatternKit.Generators.Sidecar; - [GenerateSidecar(typeof(string), typeof(int))] - public static class SidecarHost; - """), - Compile(""" - using PatternKit.Generators.Sidecar; - [GenerateSidecar(typeof(string), typeof(int))] - public static partial class SidecarHost; - """), - Compile(""" - using PatternKit.Cloud.Sidecar; - using PatternKit.Generators.Sidecar; - [GenerateSidecar(typeof(string), typeof(int))] - public static partial class SidecarHost - { - [SidecarBefore("trace")] - private static string Trace(SidecarContext ctx) => ctx.Request; - [SidecarHandler] - private static int Handle(SidecarContext ctx) => 1; - } - """), - Compile(""" - using PatternKit.Cloud.Sidecar; - using PatternKit.Generators.Sidecar; - [GenerateSidecar(typeof(string), typeof(int))] - public static partial class SidecarHost + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Equal(3, result.GeneratedSources.Count); + + var combined = string.Join("\n", result.GeneratedSources); + ScenarioExpect.Contains("internal abstract partial class AbstractSidecar", combined); + ScenarioExpect.Contains("public sealed partial class SealedSidecar", combined); + ScenarioExpect.Contains("internal partial struct StructSidecar", combined); + ScenarioExpect.Contains("Create(\"sidecar\")", combined); + ScenarioExpect.Contains("Create(\"tenant\\\\\\\"sidecar\")", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Generates nested Sidecar host wrappers")] + [Fact] + public Task Generates_Nested_Sidecar_Host_Wrappers() + => Given("nested Sidecar declarations", () => Compile(""" + using PatternKit.Cloud.Sidecar; + using PatternKit.Generators.Sidecar; + namespace Demo; + public sealed record OrderRequest(string OrderId); + public sealed record OrderResponse(string Confirmation); + + public partial class SidecarContainer + { + private partial class PrivateHost { - [SidecarBefore("trace")] - private static void Trace(SidecarContext ctx) { } - [SidecarAfter("TRACE")] - private static void Metrics(SidecarContext ctx, int response) { } - [SidecarHandler] - private static int Handle(SidecarContext ctx) => 1; + [GenerateSidecar(typeof(OrderRequest), typeof(OrderResponse))] + protected partial class ProtectedSidecar + { + [SidecarBefore("trace")] + private static void Trace(SidecarContext ctx) { } + [SidecarHandler] + private static OrderResponse Handle(SidecarContext ctx) => new(ctx.Request.OrderId); + } + + [GenerateSidecar(typeof(OrderRequest), typeof(OrderResponse))] + private protected partial class PrivateProtectedSidecar + { + [SidecarBefore("trace")] + private static void Trace(SidecarContext ctx) { } + [SidecarHandler] + private static OrderResponse Handle(SidecarContext ctx) => new(ctx.Request.OrderId); + } + + [GenerateSidecar(typeof(OrderRequest), typeof(OrderResponse))] + protected internal partial class ProtectedInternalSidecar + { + [SidecarBefore("trace")] + private static void Trace(SidecarContext ctx) { } + [SidecarHandler] + private static OrderResponse Handle(SidecarContext ctx) => new(ctx.Request.OrderId); + } } - """) - }) - .Then("diagnostics identify invalid declarations", results => + } + """)) + .Then("generated sources preserve containing partial type wrappers", result => { - var ids = results.SelectMany(static result => result.Diagnostics.Select(static diagnostic => diagnostic.Id)).ToArray(); - ScenarioExpect.Contains(ids, static id => id == "PKSC001"); - ScenarioExpect.Contains(ids, static id => id == "PKSC002"); - ScenarioExpect.Contains(ids, static id => id == "PKSC003"); - ScenarioExpect.Contains(ids, static id => id == "PKSC004"); + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Equal(3, result.GeneratedSources.Count); + + var combined = string.Join("\n", result.GeneratedSources); + ScenarioExpect.Contains("public partial class SidecarContainer", combined); + ScenarioExpect.Contains("private partial class PrivateHost", combined); + ScenarioExpect.Contains("protected partial class ProtectedSidecar", combined); + ScenarioExpect.Contains("private protected partial class PrivateProtectedSidecar", combined); + ScenarioExpect.Contains("protected internal partial class ProtectedInternalSidecar", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); }) .AssertPassed(); + [Scenario("Skips malformed Sidecar type arguments")] + [Theory] + [InlineData("null!", "typeof(OrderResponse)")] + [InlineData("typeof(OrderRequest)", "null!")] + public Task Skips_Malformed_Sidecar_Type_Arguments(string requestType, string responseType) + => Given("a Sidecar declaration with a null type argument", () => Compile($$""" + using PatternKit.Cloud.Sidecar; + using PatternKit.Generators.Sidecar; + public sealed record OrderRequest(string OrderId); + public sealed record OrderResponse(string Confirmation); + [GenerateSidecar({{requestType}}, {{responseType}})] + public static partial class OrderSidecars + { + [SidecarBefore("trace")] + private static void Trace(SidecarContext ctx) { } + [SidecarHandler] + private static OrderResponse Handle(SidecarContext ctx) => new(ctx.Request.OrderId); + } + """)) + .Then("no source is generated", result => + ScenarioExpect.Empty(result.GeneratedSources)) + .AssertPassed(); + private static GeneratorResult Compile(string source) { var compilation = RoslynTestHelpers.CreateCompilation(