From 6a1814bd38a838e9db8955f5a99b3b0b0917cd18 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 29 May 2026 15:13:14 -0500 Subject: [PATCH] test: cover repository generator hosts --- .../Repository/RepositoryGenerator.cs | 53 +++- .../RepositoryGeneratorTests.cs | 232 ++++++++++++------ 2 files changed, 205 insertions(+), 80 deletions(-) diff --git a/src/PatternKit.Generators/Repository/RepositoryGenerator.cs b/src/PatternKit.Generators/Repository/RepositoryGenerator.cs index 3388f96d..ad3758b4 100644 --- a/src/PatternKit.Generators/Repository/RepositoryGenerator.cs +++ b/src/PatternKit.Generators/Repository/RepositoryGenerator.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using System.Text; using Microsoft.CodeAnalysis; @@ -105,6 +106,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); + 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.Repository.InMemoryRepository<") + .Append(entityName).Append(", ").Append(keyName).Append("> ").Append(factoryName).AppendLine("()"); + sb.Append(bodyIndent).Append("=> global::PatternKit.Application.Repository.InMemoryRepository<") + .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 "); @@ -112,14 +156,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.Repository.InMemoryRepository<") - .Append(entityName).Append(", ").Append(keyName).Append("> ").Append(factoryName).AppendLine("()"); - sb.Append(" => global::PatternKit.Application.Repository.InMemoryRepository<") - .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/test/PatternKit.Generators.Tests/RepositoryGeneratorTests.cs b/test/PatternKit.Generators.Tests/RepositoryGeneratorTests.cs index 9fd327ab..a0a82694 100644 --- a/test/PatternKit.Generators.Tests/RepositoryGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/RepositoryGeneratorTests.cs @@ -1,21 +1,22 @@ using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; using PatternKit.Application.Repository; using PatternKit.Generators.Repository; using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; namespace PatternKit.Generators.Tests; -public sealed class RepositoryGeneratorTests +[Feature("Repository generator")] +public sealed partial class RepositoryGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) { - [Scenario("Generates repository factory")] + [Scenario("Generator emits repository factory")] [Fact] - public void GeneratesRepositoryFactory() - { - var source = """ + public Task Generator_Emits_Repository_Factory() + => Given("a valid repository declaration", () => Compile(""" using PatternKit.Generators.Repository; - namespace MyApp; + namespace Demo; public sealed record Order(string Id); @@ -25,90 +26,177 @@ public static partial class OrderRepositoryFactory [RepositoryKeySelector] private static string SelectKey(Order order) => order.Id; } + """)) + .Then("generated source creates the repository", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("public static partial class OrderRepositoryFactory", source); + ScenarioExpect.Contains("Build()", source); + ScenarioExpect.Contains("InMemoryRepository.Create(SelectKey).Build()", source); + ScenarioExpect.True(result.EmitSuccess, result.EmitDiagnostics); + }) + .AssertPassed(); + + [Scenario("Generator reports invalid repository declarations")] + [Theory] + [InlineData("public static class OrderRepositoryFactory { [RepositoryKeySelector] private static string SelectKey(Order order) => order.Id; }", "PKREP001")] + [InlineData("public static partial class OrderRepositoryFactory;", "PKREP002")] + [InlineData("public static partial class OrderRepositoryFactory { [RepositoryKeySelector] private static string One(Order order) => order.Id; [RepositoryKeySelector] private static string Two(Order order) => order.Id; }", "PKREP002")] + [InlineData("public partial class OrderRepositoryFactory { [RepositoryKeySelector] private string SelectKey(Order order) => order.Id; }", "PKREP003")] + [InlineData("public static partial class OrderRepositoryFactory { [RepositoryKeySelector] private static T SelectKey(Order order) => default!; }", "PKREP003")] + [InlineData("public static partial class OrderRepositoryFactory { [RepositoryKeySelector] private static string SelectKey() => string.Empty; }", "PKREP003")] + [InlineData("public static partial class OrderRepositoryFactory { [RepositoryKeySelector] private static string SelectKey(Order order, string tenant) => order.Id; }", "PKREP003")] + [InlineData("public static partial class OrderRepositoryFactory { [RepositoryKeySelector] private static string SelectKey(string order) => order; }", "PKREP003")] + [InlineData("public static partial class OrderRepositoryFactory { [RepositoryKeySelector] private static int SelectKey(Order order) => 1; }", "PKREP003")] + public Task Generator_Reports_Invalid_Repository_Declarations(string declaration, string diagnosticId) + => Given("an invalid repository declaration", () => Compile($$""" + using PatternKit.Generators.Repository; + public sealed record Order(string Id); + [GenerateRepository(typeof(Order), typeof(string))] + {{declaration}} + """)) + .Then("the expected diagnostic is reported", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == diagnosticId)) + .AssertPassed(); - public static class Demo - { - public static object Run() => OrderRepositoryFactory.Build(); - } - """; - - var comp = CreateCompilation(source, nameof(GeneratesRepositoryFactory)); - var gen = new RepositoryGenerator(); - _ = 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("OrderRepositoryFactory.Repository.g.cs", generated.HintName); - var text = generated.SourceText.ToString(); - ScenarioExpect.Contains("Build", text); - ScenarioExpect.Contains("InMemoryRepository.Create(SelectKey).Build()", text); - ScenarioExpect.True(updated.Emit(Stream.Null).Success); - } - - [Scenario("Reports diagnostic for non partial repository")] + [Scenario("Generator emits repository defaults and type shapes")] [Fact] - public void ReportsDiagnosticForNonPartialRepository() - { - var source = """ + public Task Generator_Emits_Repository_Defaults_And_Type_Shapes() + => Given("repository declarations using default names and different host shapes", () => Compile(""" using PatternKit.Generators.Repository; + + namespace Demo; + public sealed record Order(string Id); - [GenerateRepository(typeof(Order), typeof(string))] - public static class OrderRepositoryFactory; - """; - var comp = CreateCompilation(source, nameof(ReportsDiagnosticForNonPartialRepository)); - var gen = new RepositoryGenerator(); - _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + [GenerateRepository(typeof(Order), typeof(string))] + internal abstract partial class AbstractRepositoryFactory + { + [RepositoryKeySelector] + private static string SelectKey(Order order) => order.Id; + } - var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); - ScenarioExpect.Equal("PKREP001", diagnostic.Id); - } + [GenerateRepository(typeof(Order), typeof(string))] + public sealed partial class SealedRepositoryFactory + { + [RepositoryKeySelector] + private static string SelectKey(Order order) => order.Id; + } - [Scenario("Reports diagnostic for missing repository key selector")] + [GenerateRepository(typeof(Order), typeof(string))] + internal partial struct StructRepositoryFactory + { + [RepositoryKeySelector] + private static string SelectKey(Order order) => order.Id; + } + """)) + .Then("generated sources preserve host shape and default factory 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 AbstractRepositoryFactory", combined); + ScenarioExpect.Contains("InMemoryRepository Create()", combined); + ScenarioExpect.Contains("public sealed partial class SealedRepositoryFactory", combined); + ScenarioExpect.Contains("internal partial struct StructRepositoryFactory", combined); + ScenarioExpect.True(result.EmitSuccess, result.EmitDiagnostics); + }) + .AssertPassed(); + + [Scenario("Generator emits nested repository host wrappers")] [Fact] - public void ReportsDiagnosticForMissingRepositoryKeySelector() - { - var source = """ + public Task Generator_Emits_Nested_Repository_Host_Wrappers() + => Given("nested repository declarations with non-public accessibility", () => Compile(""" using PatternKit.Generators.Repository; - public sealed record Order(string Id); - [GenerateRepository(typeof(Order), typeof(string))] - public static partial class OrderRepositoryFactory; - """; - var comp = CreateCompilation(source, nameof(ReportsDiagnosticForMissingRepositoryKeySelector)); - var gen = new RepositoryGenerator(); - _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + namespace Demo; - var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); - ScenarioExpect.Equal("PKREP002", diagnostic.Id); - } + public sealed record Order(string Id); - [Scenario("Reports diagnostic for invalid repository key selector")] - [Fact] - public void ReportsDiagnosticForInvalidRepositoryKeySelector() - { - var source = """ + public partial class RepositoryContainer + { + private partial class PrivateHost + { + [GenerateRepository(typeof(Order), typeof(string))] + protected partial class ProtectedRepository + { + [RepositoryKeySelector] + private static string SelectKey(Order order) => order.Id; + } + + [GenerateRepository(typeof(Order), typeof(string))] + private protected partial class PrivateProtectedRepository + { + [RepositoryKeySelector] + private static string SelectKey(Order order) => order.Id; + } + + [GenerateRepository(typeof(Order), typeof(string))] + protected internal partial class ProtectedInternalRepository + { + [RepositoryKeySelector] + 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 RepositoryContainer", combined); + ScenarioExpect.Contains("private partial class PrivateHost", combined); + ScenarioExpect.Contains("protected partial class ProtectedRepository", combined); + ScenarioExpect.Contains("private protected partial class PrivateProtectedRepository", combined); + ScenarioExpect.Contains("protected internal partial class ProtectedInternalRepository", combined); + ScenarioExpect.True(result.EmitSuccess, result.EmitDiagnostics); + }) + .AssertPassed(); + + [Scenario("Generator skips malformed repository type arguments")] + [Theory] + [InlineData("null!", "typeof(string)")] + [InlineData("typeof(Order)", "null!")] + public Task Generator_Skips_Malformed_Repository_Type_Arguments(string entityType, string keyType) + => Given("a repository declaration with a null type argument", () => Compile($$""" using PatternKit.Generators.Repository; + public sealed record Order(string Id); - [GenerateRepository(typeof(Order), typeof(string))] + + [GenerateRepository({{entityType}}, {{keyType}})] public static partial class OrderRepositoryFactory { [RepositoryKeySelector] - private static int SelectKey(Order order) => 1; + private static string SelectKey(Order order) => order.Id; } - """; - - var comp = CreateCompilation(source, nameof(ReportsDiagnosticForInvalidRepositoryKeySelector)); - var gen = new RepositoryGenerator(); - _ = 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("PKREP003", diagnostic.Id); - } - - private static CSharpCompilation CreateCompilation(string source, string assemblyName) - => RoslynTestHelpers.CreateCompilation( + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( source, - assemblyName, + "RepositoryGeneratorTests", extra: MetadataReference.CreateFromFile(typeof(InMemoryRepository<,>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new RepositoryGenerator(), 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, + string.Join("\n", emit.Diagnostics)); + } + + private sealed record GeneratorResult( + IReadOnlyList Diagnostics, + IReadOnlyList GeneratedSources, + bool EmitSuccess, + string EmitDiagnostics); }