From 4abaf37bc49fadc1e892e925d99ac4685a37cc6e Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Sat, 30 May 2026 22:33:45 -0500 Subject: [PATCH] test: harden memento generator coverage --- src/PatternKit.Generators/MementoGenerator.cs | 41 +++- .../DecoratorGeneratorTests.cs | 35 ++++ .../MementoGeneratorTests.cs | 188 ++++++++++++++++++ 3 files changed, 254 insertions(+), 10 deletions(-) diff --git a/src/PatternKit.Generators/MementoGenerator.cs b/src/PatternKit.Generators/MementoGenerator.cs index f8db7105..a9a611be 100644 --- a/src/PatternKit.Generators/MementoGenerator.cs +++ b/src/PatternKit.Generators/MementoGenerator.cs @@ -348,17 +348,20 @@ private string GenerateMementoStruct(TypeInfo typeInfo, SourceProductionContext } sb.AppendLine(); - // Constructor - sb.Append($" private {typeInfo.TypeName}Memento("); - sb.Append(string.Join(", ", typeInfo.Members.Select(m => $"{m.Type} {ToCamelCase(m.Name)}"))); - sb.AppendLine(")"); - sb.AppendLine(" {"); - foreach (var member in typeInfo.Members) + if (typeInfo.Members.Count > 0) { - sb.AppendLine($" {member.Name} = {ToCamelCase(member.Name)};"); + // Constructor + sb.Append($" private {typeInfo.TypeName}Memento("); + sb.Append(string.Join(", ", typeInfo.Members.Select(m => $"{m.Type} {ToCamelCase(m.Name)}"))); + sb.AppendLine(")"); + sb.AppendLine(" {"); + foreach (var member in typeInfo.Members) + { + sb.AppendLine($" {member.Name} = {ToCamelCase(member.Name)};"); + } + sb.AppendLine(" }"); + sb.AppendLine(); } - sb.AppendLine(" }"); - sb.AppendLine(); // Capture method GenerateCaptureMethod(sb, typeInfo); @@ -449,7 +452,7 @@ private void GenerateRestoreNewMethod(StringBuilder sb, TypeInfo typeInfo, Sourc else { // Fall back to object initializer - sb.AppendLine("()"); + AppendRecordFallbackConstructor(sb, typeInfo); sb.AppendLine(" {"); foreach (var member in typeInfo.Members) { @@ -487,6 +490,24 @@ private void GenerateRestoreNewMethod(StringBuilder sb, TypeInfo typeInfo, Sourc sb.AppendLine(" }"); } + private static void AppendRecordFallbackConstructor(StringBuilder sb, TypeInfo typeInfo) + { + var fallbackCtor = typeInfo.TypeSymbol.Constructors + .Where(c => !c.IsStatic && c.DeclaredAccessibility == Accessibility.Public) + .OrderBy(c => c.Parameters.Length) + .FirstOrDefault(); + + if (fallbackCtor is null || fallbackCtor.Parameters.Length == 0) + { + sb.AppendLine("()"); + return; + } + + sb.Append("("); + sb.Append(string.Join(", ", fallbackCtor.Parameters.Select(_ => "default!"))); + sb.AppendLine(")"); + } + private void GenerateInPlaceRestoreMethod(StringBuilder sb, TypeInfo typeInfo) { sb.AppendLine($" /// Restores the memento state to an existing originator instance (in-place)."); diff --git a/test/PatternKit.Generators.Tests/DecoratorGeneratorTests.cs b/test/PatternKit.Generators.Tests/DecoratorGeneratorTests.cs index d1c3f68c..373d16d0 100644 --- a/test/PatternKit.Generators.Tests/DecoratorGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/DecoratorGeneratorTests.cs @@ -1206,4 +1206,39 @@ string Format( var emit = updated.Emit(Stream.Null); ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); } + + [Scenario("Diagnostic PKDEC004 InaccessiblePropertyDeclaration")] + [Fact] + public void Diagnostic_PKDEC004_InaccessiblePropertyDeclaration() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator] + public abstract class SecretRepository + { + private protected abstract string Confidential { get; } + public abstract string Visible { get; } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Diagnostic_PKDEC004_InaccessiblePropertyDeclaration)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + ScenarioExpect.Contains(result.Results.SelectMany(r => r.Diagnostics), d => d.Id == "PKDEC004" && d.GetMessage().Contains("Confidential")); + + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .Single(gs => gs.HintName == "TestNamespace_SecretRepository.Decorator.g.cs") + .SourceText.ToString(); + + ScenarioExpect.Contains("Visible", generatedSource); + ScenarioExpect.DoesNotContain("Confidential", generatedSource); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } } diff --git a/test/PatternKit.Generators.Tests/MementoGeneratorTests.cs b/test/PatternKit.Generators.Tests/MementoGeneratorTests.cs index da174744..94faaa7a 100644 --- a/test/PatternKit.Generators.Tests/MementoGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/MementoGeneratorTests.cs @@ -399,4 +399,192 @@ public partial record class EditorState(string Text, int Cursor); ScenarioExpect.Matches(@"private\s+const\s+int\s+MaxCapacity\s*=\s*50", caretakerSource); ScenarioExpect.Matches(@"if\s*\(\s*_states\.Count\s*>\s*MaxCapacity\s*\)", caretakerSource); } + + [Scenario("ExplicitCaptureStrategies AreAcceptedWithoutReferenceWarnings")] + [Fact] + public void ExplicitCaptureStrategies_AreAcceptedWithoutReferenceWarnings() + { + const string source = """ + using PatternKit.Generators; + using System.Collections.Generic; + + namespace TestNamespace; + + [Memento] + public partial class Document + { + [MementoStrategy(MementoCaptureStrategy.ByReference)] + public List Tags { get; set; } = new(); + + [MementoStrategy(MementoCaptureStrategy.Clone)] + public CloneableBuffer Cloneable { get; set; } = new(); + + [MementoStrategy(MementoCaptureStrategy.DeepCopy)] + public CloneableBuffer Deep { get; set; } = new(); + + [MementoStrategy(MementoCaptureStrategy.Custom)] + public CloneableBuffer Custom { get; set; } = new(); + } + + public sealed class CloneableBuffer + { + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ExplicitCaptureStrategies_AreAcceptedWithoutReferenceWarnings)); + var gen = new MementoGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + ScenarioExpect.DoesNotContain(result.Results.SelectMany(r => r.Diagnostics), d => d.Id == "PKMEM003"); + + var mementoSource = result.Results + .SelectMany(r => r.GeneratedSources) + .Single(gs => gs.HintName == "Document.Memento.g.cs") + .SourceText.ToString(); + + ScenarioExpect.Contains("global::System.Collections.Generic.List Tags", mementoSource); + ScenarioExpect.Contains("global::TestNamespace.CloneableBuffer Cloneable", mementoSource); + ScenarioExpect.Contains("global::TestNamespace.CloneableBuffer Deep", mementoSource); + ScenarioExpect.Contains("global::TestNamespace.CloneableBuffer Custom", mementoSource); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("RecordWithoutPrimaryConstructor RestoresWithObjectInitializer")] + [Fact] + public void RecordWithoutPrimaryConstructor_RestoresWithObjectInitializer() + { + const string source = """ + using PatternKit.Generators; + + namespace TestNamespace; + + [Memento] + public partial record class EditorState + { + public string Text { get; init; } = ""; + public int Cursor { get; init; } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(RecordWithoutPrimaryConstructor_RestoresWithObjectInitializer)); + var gen = new MementoGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + ScenarioExpect.All(result.Results, r => ScenarioExpect.Empty(r.Diagnostics)); + + var mementoSource = result.Results + .SelectMany(r => r.GeneratedSources) + .Single(gs => gs.HintName == "EditorState.Memento.g.cs") + .SourceText.ToString(); + + ScenarioExpect.Contains("return new global::TestNamespace.EditorState()", mementoSource); + ScenarioExpect.Contains("Text = this.Text,", mementoSource); + ScenarioExpect.Contains("Cursor = this.Cursor,", mementoSource); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("ClassWithOnlyReadonlyMembers RestoresWithDefaultConstructor")] + [Fact] + public void ClassWithOnlyReadonlyMembers_RestoresWithDefaultConstructor() + { + const string source = """ + using PatternKit.Generators; + + namespace TestNamespace; + + [Memento] + public partial class ReadOnlyState + { + public readonly int Version; + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ClassWithOnlyReadonlyMembers_RestoresWithDefaultConstructor)); + var gen = new MementoGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + ScenarioExpect.All(result.Results, r => ScenarioExpect.Empty(r.Diagnostics)); + + var mementoSource = result.Results + .SelectMany(r => r.GeneratedSources) + .Single(gs => gs.HintName == "ReadOnlyState.Memento.g.cs") + .SourceText.ToString(); + + ScenarioExpect.Contains("public int Version { get; }", mementoSource); + ScenarioExpect.Contains("return new global::TestNamespace.ReadOnlyState();", mementoSource); + ScenarioExpect.DoesNotContain("originator.Version = this.Version;", mementoSource); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("RecordWithNoCapturedMembers RestoresWithDefaultConstructor")] + [Fact] + public void RecordWithNoCapturedMembers_RestoresWithDefaultConstructor() + { + const string source = """ + using PatternKit.Generators; + + namespace TestNamespace; + + [Memento] + public partial record class EmptyState; + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(RecordWithNoCapturedMembers_RestoresWithDefaultConstructor)); + var gen = new MementoGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + ScenarioExpect.All(result.Results, r => ScenarioExpect.Empty(r.Diagnostics)); + + var mementoSource = result.Results + .SelectMany(r => r.GeneratedSources) + .Single(gs => gs.HintName == "EmptyState.Memento.g.cs") + .SourceText.ToString(); + + ScenarioExpect.DoesNotContain("private EmptyStateMemento()", mementoSource); + ScenarioExpect.Contains("return new global::TestNamespace.EmptyState();", mementoSource); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("RecordWithIgnoredPrimaryConstructorMember FallsBackToObjectInitializer")] + [Fact] + public void RecordWithIgnoredPrimaryConstructorMember_FallsBackToObjectInitializer() + { + const string source = """ + using PatternKit.Generators; + + namespace TestNamespace; + + [Memento] + public partial record class FilteredState([property: MementoIgnore] string Raw) + { + public string Name { get; init; } = Raw; + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(RecordWithIgnoredPrimaryConstructorMember_FallsBackToObjectInitializer)); + var gen = new MementoGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + ScenarioExpect.All(result.Results, r => ScenarioExpect.Empty(r.Diagnostics)); + + var mementoSource = result.Results + .SelectMany(r => r.GeneratedSources) + .Single(gs => gs.HintName == "FilteredState.Memento.g.cs") + .SourceText.ToString(); + + ScenarioExpect.Contains("return new global::TestNamespace.FilteredState(default!)", mementoSource); + ScenarioExpect.Contains("Name = this.Name,", mementoSource); + ScenarioExpect.DoesNotContain("Raw = this.Raw", mementoSource); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } }