using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Text; using MapTo.Extensions; using MapTo.Sources; using MapTo.Tests.Extensions; using MapTo.Tests.Infrastructure; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; using Shouldly; using Xunit; using static MapTo.Extensions.GeneratorExecutionContextExtensions; namespace MapTo.Tests { public class Tests { private const int Indent1 = 4; private const int Indent2 = Indent1 * 2; private const int Indent3 = Indent1 * 3; private static readonly Location IgnoreLocation = Location.None; private static readonly Dictionary DefaultAnalyzerOptions = new() { [GetBuildPropertyName(nameof(SourceGenerationOptions.GenerateXmlDocument))] = "false" }; private static readonly string ExpectedAttribute = $@"{Constants.GeneratedFilesHeader} using System; namespace MapTo {{ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] public sealed class MapFromAttribute : Attribute {{ public MapFromAttribute(Type sourceType) {{ SourceType = sourceType; }} public Type SourceType {{ get; }} }} }}"; private record SourceGeneratorOptions( bool UseMapToNamespace = false, string SourceClassNamespace = "Test.Models", int ClassPropertiesCount = 3, int SourceClassPropertiesCount = 3, Action PropertyBuilder = null, Action SourcePropertyBuilder = null); private static string GetSourceText(SourceGeneratorOptions options = null) { const string ns = "Test"; options ??= new SourceGeneratorOptions(); var hasDifferentSourceNamespace = options.SourceClassNamespace != ns; var builder = new StringBuilder(); builder.AppendLine("//"); builder.AppendLine("// Test source code."); builder.AppendLine("//"); builder.AppendLine(); if (options.UseMapToNamespace) { builder.AppendFormat("using {0};", Constants.RootNamespace).AppendLine(); } builder .AppendFormat("using {0};", options.SourceClassNamespace) .AppendLine() .AppendLine(); builder .AppendFormat("namespace {0}", ns) .AppendOpeningBracket(); if (hasDifferentSourceNamespace && options.UseMapToNamespace) { builder .PadLeft(Indent1) .AppendFormat("using {0};", options.SourceClassNamespace) .AppendLine() .AppendLine(); } builder .PadLeft(Indent1) .AppendLine(options.UseMapToNamespace ? "[MapFrom(typeof(Baz))]": "[MapTo.MapFrom(typeof(Baz))]") .PadLeft(Indent1).Append("public partial class Foo") .AppendOpeningBracket(Indent1); for (var i = 1; i <= options.ClassPropertiesCount; i++) { builder .PadLeft(Indent2) .AppendLine(i % 2 == 0 ? $"public int Prop{i} {{ get; set; }}" : $"public int Prop{i} {{ get; }}"); } options.PropertyBuilder?.Invoke(builder); builder .AppendClosingBracket(Indent1, false) .AppendClosingBracket() .AppendLine() .AppendLine(); builder .AppendFormat("namespace {0}", options.SourceClassNamespace) .AppendOpeningBracket() .PadLeft(Indent1).Append("public class Baz") .AppendOpeningBracket(Indent1); for (var i = 1; i <= options.SourceClassPropertiesCount; i++) { builder .PadLeft(Indent2) .AppendLine(i % 2 == 0 ? $"public int Prop{i} {{ get; set; }}" : $"public int Prop{i} {{ get; }}"); } options.SourcePropertyBuilder?.Invoke(builder); builder .AppendClosingBracket(Indent1, false) .AppendClosingBracket(); return builder.ToString(); } [Fact] public void VerifyIgnorePropertyAttribute() { // Arrange const string source = ""; var expectedAttribute = $@" {Constants.GeneratedFilesHeader} using System; namespace MapTo {{ [AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] public sealed class IgnorePropertyAttribute : Attribute {{ }} }} ".Trim(); // Act var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions); // Assert diagnostics.ShouldBeSuccessful(); compilation.SyntaxTrees.ShouldContainSource(IgnorePropertyAttributeSource.AttributeName, expectedAttribute); } [Fact] public void VerifyMapToAttribute() { // Arrange const string source = ""; // Act var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions); // Assert diagnostics.ShouldBeSuccessful(); compilation.SyntaxTrees.ShouldContainSource(MapFromAttributeSource.AttributeName, ExpectedAttribute); } [Fact] public void When_FoundMatchingPropertyNameWithDifferentTypes_Should_ReportError() { // Arrange var source = GetSourceText(new SourceGeneratorOptions( true, PropertyBuilder: builder => { builder .PadLeft(Indent2).AppendLine("public string Prop4 { get; set; }"); }, SourcePropertyBuilder: builder => builder.PadLeft(Indent2).AppendLine("public int Prop4 { get; set; }"))); // Act var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions); // Assert var expectedError = DiagnosticProvider.NoMatchingPropertyTypeFoundError(GetSourcePropertySymbol("Prop4", compilation)); diagnostics.ShouldBeUnsuccessful(expectedError); } [Fact] public void When_IgnorePropertyAttributeIsSpecified_Should_NotGenerateMappingsForThatProperty() { // Arrange var source = GetSourceText(new SourceGeneratorOptions( true, PropertyBuilder: builder => { builder .PadLeft(Indent2).AppendLine("[IgnoreProperty]") .PadLeft(Indent2).AppendLine("public int Prop4 { get; set; }"); }, SourcePropertyBuilder: builder => builder.PadLeft(Indent2).AppendLine("public int Prop4 { get; set; }"))); var expectedResult = @" partial class Foo { public Foo(Test.Models.Baz baz) { if (baz == null) throw new ArgumentNullException(nameof(baz)); Prop1 = baz.Prop1; Prop2 = baz.Prop2; Prop3 = baz.Prop3; } ".Trim(); // Act var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions); // Assert diagnostics.ShouldBeSuccessful(); compilation.SyntaxTrees.Last().ToString().ShouldContain(expectedResult); } [Fact] public void When_MappingsModifierOptionIsSetToInternal_Should_GenerateThoseMethodsWithInternalAccessModifier() { // Arrange var source = GetSourceText(); var configOptions = new Dictionary { [GetBuildPropertyName(nameof(SourceGenerationOptions.GeneratedMethodsAccessModifier))] = "Internal", [GetBuildPropertyName(nameof(SourceGenerationOptions.GenerateXmlDocument))] = "false" }; var expectedExtension = @" internal static partial class BazToFooExtensions { internal static Foo ToFoo(this Test.Models.Baz baz) { return baz == null ? null : new Foo(baz); } }".Trim(); var expectedFactory = @" internal static Foo From(Test.Models.Baz baz) { return baz == null ? null : new Foo(baz); }".Trim(); // Act var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: configOptions); // Assert diagnostics.ShouldBeSuccessful(); var syntaxTree = compilation.SyntaxTrees.Last().ToString(); syntaxTree.ShouldContain(expectedFactory); syntaxTree.ShouldContain(expectedExtension); } [Fact] public void When_MapToAttributeFound_Should_GenerateTheClass() { // Arrange const string source = @" using MapTo; namespace Test { [MapFrom(typeof(Baz))] public partial class Foo { public int Prop1 { get; set; } } public class Baz { public int Prop1 { get; set; } } } "; const string expectedResult = @" // using System; namespace Test { partial class Foo { public Foo(Test.Baz baz) { if (baz == null) throw new ArgumentNullException(nameof(baz)); Prop1 = baz.Prop1; } "; // Act var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions); // Assert diagnostics.ShouldBeSuccessful(); compilation.SyntaxTrees.Last().ToString().ShouldStartWith(expectedResult.Trim()); } [Fact] public void When_MapToAttributeFoundWithoutMatchingProperties_Should_ReportError() { // Arrange const string source = @" using MapTo; namespace Test { [MapFrom(typeof(Baz))] public partial class Foo { } public class Baz { public int Prop1 { get; set; } } } "; // Act var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source); // Assert var fooType = compilation.GetTypeByMetadataName("Test.Foo"); fooType.ShouldNotBeNull(); var bazType = compilation.GetTypeByMetadataName("Test.Baz"); bazType.ShouldNotBeNull(); var expectedDiagnostic = DiagnosticProvider.NoMatchingPropertyFoundError(fooType.Locations.Single(), fooType, bazType); var error = diagnostics.FirstOrDefault(d => d.Id == expectedDiagnostic.Id); error.ShouldNotBeNull(); } [Fact] public void When_MapToAttributeWithNamespaceFound_Should_GenerateTheClass() { // Arrange const string source = @" namespace Test { [MapTo.MapFrom(typeof(Baz))] public partial class Foo { public int Prop1 { get; set; } } public class Baz { public int Prop1 { get; set; } } } "; const string expectedResult = @" // using System; namespace Test { partial class Foo { public Foo(Test.Baz baz) { if (baz == null) throw new ArgumentNullException(nameof(baz)); Prop1 = baz.Prop1; } "; // Act var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions); // Assert diagnostics.ShouldBeSuccessful(); compilation.SyntaxTrees.Last().ToString().ShouldStartWith(expectedResult.Trim()); } [Fact] public void When_NoMapToAttributeFound_Should_GenerateOnlyTheAttribute() { // Arrange const string source = ""; var expectedTypes = new[] { IgnorePropertyAttributeSource.AttributeName, MapFromAttributeSource.AttributeName, TypeConverterSource.InterfaceName }; // Act var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source); // Assert diagnostics.ShouldBeSuccessful(); compilation.SyntaxTrees .Select(s => s.ToString()) .Where(s => !string.IsNullOrWhiteSpace(s.ToString())) .All(s => expectedTypes.Any(s.Contains)) .ShouldBeTrue(); } [Fact] public void When_SourceTypeHasDifferentNamespace_Should_NotAddToUsings() { // Arrange var source = GetSourceText(new SourceGeneratorOptions(SourceClassNamespace: "Bazaar")); const string expectedResult = @" // using System; namespace Test "; // Act var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source); // Assert diagnostics.ShouldBeSuccessful(); compilation.SyntaxTrees.Last().ToString().ShouldStartWith(expectedResult.Trim()); } [Fact] public void When_SourceTypeHasMatchingProperties_Should_CreateConstructorAndAssignSrcToDest() { // Arrange var source = GetSourceText(); const string expectedResult = @" partial class Foo { public Foo(Test.Models.Baz baz) { if (baz == null) throw new ArgumentNullException(nameof(baz)); Prop1 = baz.Prop1; Prop2 = baz.Prop2; Prop3 = baz.Prop3; } "; // Act var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions); // Assert diagnostics.ShouldBeSuccessful(); compilation.SyntaxTrees.Last().ToString().ShouldContain(expectedResult.Trim()); } [Fact] public void When_SourceTypeHasMatchingProperties_Should_CreateFromStaticMethod() { // Arrange var source = GetSourceText(); const string expectedResult = @" public static Foo From(Test.Models.Baz baz) { return baz == null ? null : new Foo(baz); } "; // Act var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source); // Assert diagnostics.ShouldBeSuccessful(); compilation.SyntaxTrees.Last().ToString().ShouldContain(expectedResult.Trim()); } [Fact] public void When_SourceTypeHasMatchingProperties_Should_GenerateToExtensionMethodOnSourceType() { // Arrange var source = GetSourceText(); const string expectedResult = @" public static partial class BazToFooExtensions { public static Foo ToFoo(this Test.Models.Baz baz) { return baz == null ? null : new Foo(baz); } } "; // Act var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions); // Assert diagnostics.ShouldBeSuccessful(); compilation.SyntaxTrees.Last().ToString().ShouldContain(expectedResult.Trim()); } [Fact] public void VerifyTypeConverterInterface() { // Arrange const string source = ""; var expectedInterface = $@" {Constants.GeneratedFilesHeader} namespace MapTo {{ public interface ITypeConverter {{ TDestination Convert(TSource source); }} }} ".Trim(); // Act var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions); // Assert diagnostics.ShouldBeSuccessful(); compilation.SyntaxTrees.ShouldContainSource(TypeConverterSource.InterfaceName, expectedInterface); } [Fact] public void VerifyMapTypeConverterAttribute() { // Arrange const string source = ""; var expectedInterface = $@" {Constants.GeneratedFilesHeader} using System; namespace MapTo {{ [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] public sealed class MapTypeConverterAttribute : Attribute {{ public MapTypeConverterAttribute(Type converter) {{ Converter = converter; }} public Type Converter {{ get; }} }} }} ".Trim(); // Act var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions); // Assert diagnostics.ShouldBeSuccessful(); compilation.SyntaxTrees.ShouldContainSource(MapTypeConverterAttributeSource.AttributeName, expectedInterface); } [Fact] public void When_FoundMatchingPropertyNameWithDifferentImplicitlyConvertibleType_Should_GenerateTheProperty() { // Arrange var source = GetSourceText(new SourceGeneratorOptions( true, PropertyBuilder: builder => { builder .PadLeft(Indent2).AppendLine("public long Prop4 { get; set; }"); }, SourcePropertyBuilder: builder => builder.PadLeft(Indent2).AppendLine("public int Prop4 { get; set; }"))); var expectedResult = @" partial class Foo { public Foo(Test.Models.Baz baz) { if (baz == null) throw new ArgumentNullException(nameof(baz)); Prop1 = baz.Prop1; Prop2 = baz.Prop2; Prop3 = baz.Prop3; Prop4 = baz.Prop4; } ".Trim(); // Act var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions); // Assert diagnostics.ShouldBeSuccessful(); compilation.SyntaxTrees.Last().ToString().ShouldContain(expectedResult); } [Fact] public void When_FoundMatchingPropertyNameWithIncorrectConverterType_ShouldReportError() { // Arrange var source = GetSourceText(new SourceGeneratorOptions( true, PropertyBuilder: builder => { builder .PadLeft(Indent2).AppendLine("[IgnoreProperty]") .PadLeft(Indent2).AppendLine("public long IgnoreMe { get; set; }") .PadLeft(Indent2).AppendLine("[MapTypeConverter(typeof(Prop4Converter))]") .PadLeft(Indent2).AppendLine("public long Prop4 { get; set; }"); }, SourcePropertyBuilder: builder => builder.PadLeft(Indent2).AppendLine("public string Prop4 { get; set; }"))); source += @" namespace Test { using MapTo; public class Prop4Converter: ITypeConverter { public int Convert(string source) => int.Parse(source); } } "; // Act var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions); // Assert var expectedError = DiagnosticProvider.InvalidTypeConverterGenericTypesError(GetSourcePropertySymbol("Prop4", compilation), GetSourcePropertySymbol("Prop4", compilation, "Baz")); diagnostics.ShouldBeUnsuccessful(expectedError); } [Fact] public void When_FoundMatchingPropertyNameWithConverterType_ShouldUseTheConverterToAssignProperties() { // Arrange var source = GetSourceText(new SourceGeneratorOptions( true, PropertyBuilder: builder => { builder .PadLeft(Indent2).AppendLine("[MapTypeConverter(typeof(Prop4Converter))]") .PadLeft(Indent2).AppendLine("public long Prop4 { get; set; }"); }, SourcePropertyBuilder: builder => builder.PadLeft(Indent2).AppendLine("public string Prop4 { get; set; }"))); source += @" namespace Test { using MapTo; public class Prop4Converter: ITypeConverter { public long Convert(string source) => long.Parse(source); } } "; const string expectedSyntax = "Prop4 = new Test.Prop4Converter().Convert(baz.Prop4);"; // Act var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions); // Assert diagnostics.ShouldBeSuccessful(); compilation.SyntaxTrees.Last().ToString().ShouldContain(expectedSyntax); } private static PropertyDeclarationSyntax GetPropertyDeclarationSyntax(SyntaxTree syntaxTree, string targetPropertyName, string targetClass = "Foo") { return syntaxTree.GetRoot() .DescendantNodes() .OfType() .Single(c => c.Identifier.ValueText == targetClass) .DescendantNodes() .OfType() .Single(p => p.Identifier.ValueText == targetPropertyName); } private static IPropertySymbol GetSourcePropertySymbol(string propertyName, Compilation compilation, string targetClass = "Foo") { var syntaxTree = compilation.SyntaxTrees.First(); var propSyntax = GetPropertyDeclarationSyntax(syntaxTree, propertyName, targetClass); var semanticModel = compilation.GetSemanticModel(syntaxTree); return semanticModel.GetDeclaredSymbol(propSyntax); } } }