From 9fabe58437956fcd0df5bd35fa3ae4278d4c72a9 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 29 May 2026 21:16:19 -0500 Subject: [PATCH] test(generators): cover identity map and service layer --- .../IdentityMap/IdentityMapGenerator.cs | 53 ++++++- .../ServiceLayerOperationGenerator.cs | 62 ++++++-- .../IdentityMapGeneratorTests.cs | 122 +++++++++++++-- .../ServiceLayerGeneratorTests.cs | 145 +++++++++++++++++- 4 files changed, 347 insertions(+), 35 deletions(-) diff --git a/src/PatternKit.Generators/IdentityMap/IdentityMapGenerator.cs b/src/PatternKit.Generators/IdentityMap/IdentityMapGenerator.cs index 1b522bd8..ba85b944 100644 --- a/src/PatternKit.Generators/IdentityMap/IdentityMapGenerator.cs +++ b/src/PatternKit.Generators/IdentityMap/IdentityMapGenerator.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using System.Text; using Microsoft.CodeAnalysis; @@ -91,6 +92,49 @@ private static string GenerateSource(INamedTypeSymbol type, INamedTypeSymbol ent 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.Application.IdentityMap.IdentityMap<") + .Append(entityName).Append(", ").Append(keyName).Append("> ").Append(factoryName).AppendLine("()"); + sb.Append(bodyIndent).Append("=> global::PatternKit.Application.IdentityMap.IdentityMap<") + .Append(entityName).Append(", ").Append(keyName).Append(">.Create(").Append(selectorName).AppendLine(").Build();"); + 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 "); @@ -98,14 +142,7 @@ private static string GenerateSource(INamedTypeSymbol type, INamedTypeSymbol ent 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.IdentityMap.IdentityMap<") - .Append(entityName).Append(", ").Append(keyName).Append("> ").Append(factoryName).AppendLine("()"); - sb.Append(" => global::PatternKit.Application.IdentityMap.IdentityMap<") - .Append(entityName).Append(", ").Append(keyName).Append(">.Create(").Append(selectorName).AppendLine(").Build();"); - sb.AppendLine("}"); - return sb.ToString(); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name); } private static bool IsKeySelector(IMethodSymbol method, INamedTypeSymbol entityType, INamedTypeSymbol keyType) diff --git a/src/PatternKit.Generators/ServiceLayer/ServiceLayerOperationGenerator.cs b/src/PatternKit.Generators/ServiceLayer/ServiceLayerOperationGenerator.cs index 59d73e3a..894940b3 100644 --- a/src/PatternKit.Generators/ServiceLayer/ServiceLayerOperationGenerator.cs +++ b/src/PatternKit.Generators/ServiceLayer/ServiceLayerOperationGenerator.cs @@ -142,6 +142,54 @@ 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.Application.ServiceLayer.ServiceLayerOperation<") + .Append(requestName).Append(", ").Append(responseName).Append("> ").Append(factoryName).AppendLine("()"); + sb.AppendLine(memberIndent + "{"); + sb.Append(bodyIndent).Append("var builder = global::PatternKit.Application.ServiceLayer.ServiceLayerOperation<") + .Append(requestName).Append(", ").Append(responseName).Append(">.Create(\"").Append(Escape(operationName)).AppendLine("\");"); + foreach (var rule in rules) + sb.Append(bodyIndent).Append("builder.Require(\"").Append(Escape(rule.Code)).Append("\", \"").Append(Escape(rule.Message)).Append("\", ").Append(rule.Method.Name).AppendLine(");"); + sb.Append(bodyIndent).Append("return builder.Handle(").Append(handlerName).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 "); @@ -149,19 +197,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.Application.ServiceLayer.ServiceLayerOperation<") - .Append(requestName).Append(", ").Append(responseName).Append("> ").Append(factoryName).AppendLine("()"); - sb.AppendLine(" {"); - sb.Append(" var builder = global::PatternKit.Application.ServiceLayer.ServiceLayerOperation<") - .Append(requestName).Append(", ").Append(responseName).Append(">.Create(\"").Append(Escape(operationName)).AppendLine("\");"); - foreach (var rule in rules) - sb.Append(" builder.Require(\"").Append(Escape(rule.Code)).Append("\", \"").Append(Escape(rule.Message)).Append("\", ").Append(rule.Method.Name).AppendLine(");"); - sb.Append(" return builder.Handle(").Append(handlerName).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 bool IsHandler(IMethodSymbol method, INamedTypeSymbol requestType, INamedTypeSymbol responseType) diff --git a/test/PatternKit.Generators.Tests/IdentityMapGeneratorTests.cs b/test/PatternKit.Generators.Tests/IdentityMapGeneratorTests.cs index 61860ae5..5e30003c 100644 --- a/test/PatternKit.Generators.Tests/IdentityMapGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/IdentityMapGeneratorTests.cs @@ -29,28 +29,50 @@ public static partial class OrderIdentityMap ScenarioExpect.Empty(result.Diagnostics); ScenarioExpect.Contains("CreateMap()", ScenarioExpect.Single(result.GeneratedSources)); ScenarioExpect.Contains("IdentityMap.Create(SelectKey).Build()", result.GeneratedSources[0]); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); }) .AssertPassed(); - [Scenario("Generator emits internal struct factory in the global namespace")] + [Scenario("Generator emits identity map defaults and host shapes")] [Fact] - public Task Generator_Emits_Internal_Struct_Factory_In_The_Global_Namespace() - => Given("a valid internal struct declaration", () => Compile(""" + public Task Generator_Emits_Identity_Map_Defaults_And_Host_Shapes() + => Given("valid identity map declarations with default names and host shapes", () => Compile(""" using PatternKit.Generators.IdentityMap; + namespace Demo; public sealed record Order(string Id); + + [GenerateIdentityMap(typeof(Order), typeof(string))] + internal abstract partial class AbstractIdentityMap + { + [IdentityMapKeySelector] + private static string SelectKey(Order order) => order.Id; + } + [GenerateIdentityMap(typeof(Order), typeof(string))] - internal partial struct OrderIdentityMap + public sealed partial class SealedIdentityMap + { + [IdentityMapKeySelector] + private static string SelectKey(Order order) => order.Id; + } + + [GenerateIdentityMap(typeof(Order), typeof(string))] + internal partial struct StructIdentityMap { [IdentityMapKeySelector] private static string SelectKey(Order order) => order.Id; } """)) - .Then("generated source keeps the declaration shape and default factory name", result => + .Then("generated source keeps declaration shapes and default factory names", result => { ScenarioExpect.Empty(result.Diagnostics); - ScenarioExpect.Contains("internal partial struct OrderIdentityMap", ScenarioExpect.Single(result.GeneratedSources)); - ScenarioExpect.Contains("Create()", result.GeneratedSources[0]); - ScenarioExpect.DoesNotContain("namespace", result.GeneratedSources[0]); + ScenarioExpect.Equal(3, result.GeneratedSources.Count); + + var combined = string.Join("\n", result.GeneratedSources); + ScenarioExpect.Contains("internal abstract partial class AbstractIdentityMap", combined); + ScenarioExpect.Contains("public sealed partial class SealedIdentityMap", combined); + ScenarioExpect.Contains("internal partial struct StructIdentityMap", combined); + ScenarioExpect.Contains("Create()", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); }) .AssertPassed(); @@ -61,6 +83,8 @@ internal partial struct OrderIdentityMap [InlineData("public static partial class OrderIdentityMap { [IdentityMapKeySelector] private static string SelectKey(Order order) => order.Id; [IdentityMapKeySelector] private static string SelectAlternate(Order order) => order.Id; }", "PKIM002")] [InlineData("public static partial class OrderIdentityMap { [IdentityMapKeySelector] private static int SelectKey(Order order) => 1; }", "PKIM003")] [InlineData("public static partial class OrderIdentityMap { [IdentityMapKeySelector] private string SelectKey(Order order) => order.Id; }", "PKIM003")] + [InlineData("public static partial class OrderIdentityMap { [IdentityMapKeySelector] private static string SelectKey() => string.Empty; }", "PKIM003")] + [InlineData("public static partial class OrderIdentityMap { [IdentityMapKeySelector] private static string SelectKey(string order) => order; }", "PKIM003")] public Task Generator_Reports_Invalid_Identity_Map_Declarations(string declaration, string diagnosticId) => Given("an invalid identity map declaration", () => Compile($$""" using PatternKit.Generators.IdentityMap; @@ -72,18 +96,94 @@ public sealed record Order(string Id); ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == diagnosticId)) .AssertPassed(); + [Scenario("Generator emits nested identity map host wrappers")] + [Fact] + public Task Generator_Emits_Nested_Identity_Map_Host_Wrappers() + => Given("nested identity map declarations", () => Compile(""" + using PatternKit.Generators.IdentityMap; + namespace Demo; + public sealed record Order(string Id); + + public partial class IdentityMapContainer + { + private partial class PrivateHost + { + [GenerateIdentityMap(typeof(Order), typeof(string))] + protected partial class ProtectedIdentityMap + { + [IdentityMapKeySelector] + private static string SelectKey(Order order) => order.Id; + } + + [GenerateIdentityMap(typeof(Order), typeof(string))] + private protected partial class PrivateProtectedIdentityMap + { + [IdentityMapKeySelector] + private static string SelectKey(Order order) => order.Id; + } + + [GenerateIdentityMap(typeof(Order), typeof(string))] + protected internal partial class ProtectedInternalIdentityMap + { + [IdentityMapKeySelector] + private static string SelectKey(Order order) => order.Id; + } + } + } + """)) + .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 IdentityMapContainer", combined); + ScenarioExpect.Contains("private partial class PrivateHost", combined); + ScenarioExpect.Contains("protected partial class ProtectedIdentityMap", combined); + ScenarioExpect.Contains("private protected partial class PrivateProtectedIdentityMap", combined); + ScenarioExpect.Contains("protected internal partial class ProtectedInternalIdentityMap", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Generator skips malformed identity map type arguments")] + [Theory] + [InlineData("null!", "typeof(string)")] + [InlineData("typeof(Order)", "null!")] + public Task Generator_Skips_Malformed_Identity_Map_Type_Arguments(string entityType, string keyType) + => Given("an identity map declaration with a null type argument", () => Compile($$""" + using PatternKit.Generators.IdentityMap; + public sealed record Order(string Id); + [GenerateIdentityMap({{entityType}}, {{keyType}})] + public static partial class OrderIdentityMap + { + [IdentityMapKeySelector] + private static string SelectKey(Order order) => order.Id; + } + """)) + .Then("no source is generated", result => + ScenarioExpect.Empty(result.GeneratedSources)) + .AssertPassed(); + private static GeneratorResult Compile(string source) { var compilation = RoslynTestHelpers.CreateCompilation( source, "IdentityMapGeneratorTests", extra: MetadataReference.CreateFromFile(typeof(IdentityMap<,>).Assembly.Location)); - _ = RoslynTestHelpers.Run(compilation, new IdentityMapGenerator(), out var run, out _); + _ = RoslynTestHelpers.Run(compilation, new IdentityMapGenerator(), 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/ServiceLayerGeneratorTests.cs b/test/PatternKit.Generators.Tests/ServiceLayerGeneratorTests.cs index 6d31c6ed..07c85dd4 100644 --- a/test/PatternKit.Generators.Tests/ServiceLayerGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/ServiceLayerGeneratorTests.cs @@ -41,6 +41,7 @@ public static partial class RegisterCustomerService ScenarioExpect.Contains("Require(\"email\", \"Email is required.\", HasEmail)", source); ScenarioExpect.Contains(".Handle(Handle).Build()", source); ScenarioExpect.True(source.IndexOf("HasTenant", StringComparison.Ordinal) < source.IndexOf("HasEmail", StringComparison.Ordinal)); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); }) .AssertPassed(); @@ -50,7 +51,13 @@ public static partial class RegisterCustomerService [InlineData("public static partial class RegisterCustomerService;", "PKSL002")] [InlineData("public static partial class RegisterCustomerService { [ServiceLayerHandler] private static ValueTask One(RegisterCustomer request, CancellationToken cancellationToken) => new(new CustomerReceipt(request.Email)); [ServiceLayerHandler] private static ValueTask Two(RegisterCustomer request, CancellationToken cancellationToken) => new(new CustomerReceipt(request.Email)); }", "PKSL002")] [InlineData("public static partial class RegisterCustomerService { [ServiceLayerHandler] private static CustomerReceipt Handle(RegisterCustomer request) => new(request.Email); }", "PKSL003")] + [InlineData("public static partial class RegisterCustomerService { [ServiceLayerHandler] private ValueTask Handle(RegisterCustomer request, CancellationToken cancellationToken) => new(new CustomerReceipt(request.Email)); }", "PKSL003")] + [InlineData("public static partial class RegisterCustomerService { [ServiceLayerHandler] private static ValueTask Handle(string request, CancellationToken cancellationToken) => new(new CustomerReceipt(request)); }", "PKSL003")] + [InlineData("public static partial class RegisterCustomerService { [ServiceLayerHandler] private static ValueTask Handle(RegisterCustomer request, CancellationToken cancellationToken) => new(request.Email); }", "PKSL003")] [InlineData("public static partial class RegisterCustomerService { [ServiceLayerRule(\"email\", \"Email is required.\", 1)] private static string HasEmail(RegisterCustomer request) => request.Email; [ServiceLayerHandler] private static ValueTask Handle(RegisterCustomer request, CancellationToken cancellationToken) => new(new CustomerReceipt(request.Email)); }", "PKSL004")] + [InlineData("public static partial class RegisterCustomerService { [ServiceLayerRule(\"email\", \"Email is required.\", 1)] private bool HasEmail(RegisterCustomer request) => true; [ServiceLayerHandler] private static ValueTask Handle(RegisterCustomer request, CancellationToken cancellationToken) => new(new CustomerReceipt(request.Email)); }", "PKSL004")] + [InlineData("public static partial class RegisterCustomerService { [ServiceLayerRule(\"email\", \"Email is required.\", 1)] private static bool HasEmail() => true; [ServiceLayerHandler] private static ValueTask Handle(RegisterCustomer request, CancellationToken cancellationToken) => new(new CustomerReceipt(request.Email)); }", "PKSL004")] + [InlineData("public static partial class RegisterCustomerService { [ServiceLayerRule(\"email\", \"Email is required.\", 1)] private static bool HasEmail(string request) => true; [ServiceLayerHandler] private static ValueTask Handle(RegisterCustomer request, CancellationToken cancellationToken) => new(new CustomerReceipt(request.Email)); }", "PKSL004")] [InlineData("public static partial class RegisterCustomerService { [ServiceLayerRule(\"email\", \"Email is required.\", 1)] private static bool HasEmail(RegisterCustomer request) => true; [ServiceLayerRule(\"tenant\", \"Tenant is required.\", 1)] private static bool HasTenant(RegisterCustomer request) => true; [ServiceLayerHandler] private static ValueTask Handle(RegisterCustomer request, CancellationToken cancellationToken) => new(new CustomerReceipt(request.Email)); }", "PKSL005")] public Task Generator_Reports_Invalid_Service_Layer_Declarations(string declaration, string diagnosticId) => Given("an invalid service layer declaration", () => Compile($$""" @@ -66,18 +73,150 @@ public sealed record CustomerReceipt(string Email); ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == diagnosticId)) .AssertPassed(); + [Scenario("Generator emits service layer defaults and host shapes")] + [Fact] + public Task Generator_Emits_Service_Layer_Defaults_And_Host_Shapes() + => Given("valid service layer declarations with default names and host shapes", () => Compile(""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.ServiceLayer; + namespace Demo; + public sealed record RegisterCustomer(string Email); + public sealed record CustomerReceipt(string Email); + + [GenerateServiceLayerOperation(typeof(RegisterCustomer), typeof(CustomerReceipt))] + internal abstract partial class AbstractRegisterCustomerService + { + [ServiceLayerHandler] + private static ValueTask Handle(RegisterCustomer request, CancellationToken cancellationToken) => new(new CustomerReceipt(request.Email)); + } + + [GenerateServiceLayerOperation(typeof(RegisterCustomer), typeof(CustomerReceipt), OperationName = "tenant\\\"registration")] + public sealed partial class SealedRegisterCustomerService + { + [ServiceLayerRule("email", "Email is required.", 1)] + private static bool HasEmail(RegisterCustomer request) => !string.IsNullOrWhiteSpace(request.Email); + [ServiceLayerHandler] + private static ValueTask Handle(RegisterCustomer request, CancellationToken cancellationToken) => new(new CustomerReceipt(request.Email)); + } + + [GenerateServiceLayerOperation(typeof(RegisterCustomer), typeof(CustomerReceipt))] + internal partial struct StructRegisterCustomerService + { + [ServiceLayerHandler] + private static ValueTask Handle(RegisterCustomer request, CancellationToken cancellationToken) => new(new CustomerReceipt(request.Email)); + } + """)) + .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); + ScenarioExpect.Contains("internal abstract partial class AbstractRegisterCustomerService", combined); + ScenarioExpect.Contains("public sealed partial class SealedRegisterCustomerService", combined); + ScenarioExpect.Contains("internal partial struct StructRegisterCustomerService", combined); + ScenarioExpect.Contains("Create(\"AbstractRegisterCustomerService\")", combined); + ScenarioExpect.Contains("Create(\"tenant\\\\\\\"registration\")", combined); + ScenarioExpect.Contains("Require(\"email\", \"Email is required.\", HasEmail)", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Generator emits nested service layer host wrappers")] + [Fact] + public Task Generator_Emits_Nested_Service_Layer_Host_Wrappers() + => Given("nested service layer declarations", () => Compile(""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.ServiceLayer; + namespace Demo; + public sealed record RegisterCustomer(string Email); + public sealed record CustomerReceipt(string Email); + + public partial class ServiceLayerContainer + { + private partial class PrivateHost + { + [GenerateServiceLayerOperation(typeof(RegisterCustomer), typeof(CustomerReceipt))] + protected partial class ProtectedServiceLayer + { + [ServiceLayerHandler] + private static ValueTask Handle(RegisterCustomer request, CancellationToken cancellationToken) => new(new CustomerReceipt(request.Email)); + } + + [GenerateServiceLayerOperation(typeof(RegisterCustomer), typeof(CustomerReceipt))] + private protected partial class PrivateProtectedServiceLayer + { + [ServiceLayerHandler] + private static ValueTask Handle(RegisterCustomer request, CancellationToken cancellationToken) => new(new CustomerReceipt(request.Email)); + } + + [GenerateServiceLayerOperation(typeof(RegisterCustomer), typeof(CustomerReceipt))] + protected internal partial class ProtectedInternalServiceLayer + { + [ServiceLayerHandler] + private static ValueTask Handle(RegisterCustomer request, CancellationToken cancellationToken) => new(new CustomerReceipt(request.Email)); + } + } + } + """)) + .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 ServiceLayerContainer", combined); + ScenarioExpect.Contains("private partial class PrivateHost", combined); + ScenarioExpect.Contains("protected partial class ProtectedServiceLayer", combined); + ScenarioExpect.Contains("private protected partial class PrivateProtectedServiceLayer", combined); + ScenarioExpect.Contains("protected internal partial class ProtectedInternalServiceLayer", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Generator skips malformed service layer type arguments")] + [Theory] + [InlineData("null!", "typeof(CustomerReceipt)")] + [InlineData("typeof(RegisterCustomer)", "null!")] + public Task Generator_Skips_Malformed_Service_Layer_Type_Arguments(string requestType, string responseType) + => Given("a service layer declaration with a null type argument", () => Compile($$""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.ServiceLayer; + public sealed record RegisterCustomer(string Email); + public sealed record CustomerReceipt(string Email); + [GenerateServiceLayerOperation({{requestType}}, {{responseType}})] + public static partial class RegisterCustomerService + { + [ServiceLayerHandler] + private static ValueTask Handle(RegisterCustomer request, CancellationToken cancellationToken) => new(new CustomerReceipt(request.Email)); + } + """)) + .Then("no source is generated", result => + ScenarioExpect.Empty(result.GeneratedSources)) + .AssertPassed(); + private static GeneratorResult Compile(string source) { var compilation = RoslynTestHelpers.CreateCompilation( source, "ServiceLayerGeneratorTests", extra: MetadataReference.CreateFromFile(typeof(ServiceLayerOperation<,>).Assembly.Location)); - _ = RoslynTestHelpers.Run(compilation, new ServiceLayerOperationGenerator(), out var run, out _); + _ = RoslynTestHelpers.Run(compilation, new ServiceLayerOperationGenerator(), 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); }