From bb86b7d7e9199915e954ce7977b3a948951ece4d Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Fri, 18 Oct 2024 20:53:55 +0200 Subject: [PATCH] Better support for default interface and explicitly implemented properties --- .nuke/build.schema.json | 157 +++++------ Build/_build.csproj.DotSettings | 5 +- FluentAssertions.sln.DotSettings | 33 ++- .../Common/TypeMemberReflector.cs | 199 ++++++-------- .../Matching/MustMatchByNameRule.cs | 2 +- .../Equivalency/MemberVisibility.cs | 3 +- .../Selection/AllPropertiesSelectionRule.cs | 5 +- .../FluentAssertions/net47.verified.txt | 3 +- .../FluentAssertions/net6.0.verified.txt | 3 +- .../netcoreapp2.1.verified.txt | 1 + .../netcoreapp3.0.verified.txt | 1 + .../netstandard2.0.verified.txt | 3 +- .../netstandard2.1.verified.txt | 3 +- .../FluentAssertions.Equivalency.Specs.csproj | 1 + .../SelectionRulesSpecs.cs | 180 ++++++++++++- .../{Types => Common}/TypeExtensionsSpecs.cs | 2 +- .../Common/TypeMemberReflectorSpecs.cs | 249 ++++++++++++++++++ .../FluentAssertions.Specs.csproj | 1 + 18 files changed, 635 insertions(+), 216 deletions(-) rename Tests/FluentAssertions.Specs/{Types => Common}/TypeExtensionsSpecs.cs (99%) create mode 100644 Tests/FluentAssertions.Specs/Common/TypeMemberReflectorSpecs.cs diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index 0cc64a64ed..09b8a34c49 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -1,53 +1,99 @@ { "$schema": "http://json-schema.org/draft-04/schema#", - "$ref": "#/definitions/build", - "title": "Build Schema", + "properties": { + "Configuration": { + "type": "string", + "description": "The solution configuration to build. Default is 'Debug' (local) or 'CI' (server)", + "enum": [ + "CI", + "Debug" + ] + }, + "GenerateBinLog": { + "type": [ + "boolean", + "null" + ], + "description": "Use this parameter if you encounter build problems in any way, to generate a .binlog file which holds some useful information" + }, + "NuGetApiKey": { + "type": "string", + "description": "The key to push to Nuget", + "default": "Secrets must be entered via 'nuke :secrets [profile]'" + }, + "Solution": { + "type": "string", + "description": "Path to a solution file that is automatically loaded" + } + }, "definitions": { - "build": { - "type": "object", + "Host": { + "type": "string", + "enum": [ + "AppVeyor", + "AzurePipelines", + "Bamboo", + "Bitbucket", + "Bitrise", + "GitHubActions", + "GitLab", + "Jenkins", + "Rider", + "SpaceAutomation", + "TeamCity", + "Terminal", + "TravisCI", + "VisualStudio", + "VSCode" + ] + }, + "ExecutableTarget": { + "type": "string", + "enum": [ + "ApiChecks", + "CalculateNugetVersion", + "Clean", + "CodeCoverage", + "Compile", + "InstallNode", + "Pack", + "Push", + "Restore", + "SpellCheck", + "TestFrameworks", + "UnitTests", + "UnitTestsNet47", + "UnitTestsNet6OrGreater" + ] + }, + "Verbosity": { + "type": "string", + "description": "", + "enum": [ + "Verbose", + "Normal", + "Minimal", + "Quiet" + ] + }, + "NukeBuild": { "properties": { "Continue": { "type": "boolean", "description": "Indicates to continue a previously failed build attempt" }, - "GenerateBinLog": { - "type": "boolean", - "description": "Use this parameter if you encounter build problems in any way, to generate a .binlog file which holds some useful information" - }, "Help": { "type": "boolean", "description": "Shows the help text for this build assembly" }, "Host": { - "type": "string", "description": "Host for execution. Default is 'automatic'", - "enum": [ - "AppVeyor", - "AzurePipelines", - "Bamboo", - "Bitbucket", - "Bitrise", - "GitHubActions", - "GitLab", - "Jenkins", - "Rider", - "SpaceAutomation", - "TeamCity", - "Terminal", - "TravisCI", - "VisualStudio", - "VSCode" - ] + "$ref": "#/definitions/Host" }, "NoLogo": { "type": "boolean", "description": "Disables displaying the NUKE logo" }, - "NuGetApiKey": { - "type": "string", - "description": "The key to push to Nuget", - "default": "Secrets must be entered via 'nuke :secrets [profile]'" - }, "Partition": { "type": "string", "description": "Partition to use on CI" @@ -71,61 +117,22 @@ "type": "array", "description": "List of targets to be skipped. Empty list skips all dependencies", "items": { - "type": "string", - "enum": [ - "ApiChecks", - "CalculateNugetVersion", - "Clean", - "CodeCoverage", - "Compile", - "Pack", - "Push", - "Restore", - "SpellCheck", - "TestFrameworks", - "UnitTests", - "UnitTestsNetCore", - "UnitTestsNetFramework" - ] + "$ref": "#/definitions/ExecutableTarget" } }, - "Solution": { - "type": "string", - "description": "Path to a solution file that is automatically loaded" - }, "Target": { "type": "array", "description": "List of targets to be invoked. Default is '{default_target}'", "items": { - "type": "string", - "enum": [ - "ApiChecks", - "CalculateNugetVersion", - "Clean", - "CodeCoverage", - "Compile", - "Pack", - "Push", - "Restore", - "SpellCheck", - "TestFrameworks", - "UnitTests", - "UnitTestsNetCore", - "UnitTestsNetFramework" - ] + "$ref": "#/definitions/ExecutableTarget" } }, "Verbosity": { - "type": "string", "description": "Logging verbosity during build execution. Default is 'Normal'", - "enum": [ - "Minimal", - "Normal", - "Quiet", - "Verbose" - ] + "$ref": "#/definitions/Verbosity" } } } - } + }, + "$ref": "#/definitions/NukeBuild" } diff --git a/Build/_build.csproj.DotSettings b/Build/_build.csproj.DotSettings index 9aac7d8e8d..28494fb0c6 100644 --- a/Build/_build.csproj.DotSettings +++ b/Build/_build.csproj.DotSettings @@ -13,6 +13,8 @@ False <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy><Descriptor Staticness="Instance" AccessRightKinds="Private" Description="Instance fields (private)"><ElementKinds><Kind Name="FIELD" /><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" WarnAboutPrefixesAndSuffixes="False" Prefix="" Suffix="" Style="AaBb" /></Policy> + <Policy><Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static fields (private)"><ElementKinds><Kind Name="FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" WarnAboutPrefixesAndSuffixes="False" Prefix="" Suffix="" Style="AaBb" /></Policy> True True True @@ -21,4 +23,5 @@ True True True - True + True + True diff --git a/FluentAssertions.sln.DotSettings b/FluentAssertions.sln.DotSettings index 692c8c68da..c20c8d78f0 100644 --- a/FluentAssertions.sln.DotSettings +++ b/FluentAssertions.sln.DotSettings @@ -104,6 +104,8 @@ UseExplicitType <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy><Descriptor Staticness="Instance" AccessRightKinds="Private" Description="Instance fields (private)"><ElementKinds><Kind Name="FIELD" /><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" WarnAboutPrefixesAndSuffixes="False" Prefix="" Suffix="" Style="aaBb" /></Policy> + <Policy><Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static fields (private)"><ElementKinds><Kind Name="FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" WarnAboutPrefixesAndSuffixes="False" Prefix="" Suffix="" Style="aaBb" /></Policy> <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> @@ -154,26 +156,29 @@ True True True + True D:\Workspaces\FluentAssertions\Default.testsettings 4 False True - True - 1 - True - 0 + False + True + 0 + + False + aaa Arrange-Act-Assert - [TestMethod] -public void When_$scenario$_it_should_$behavior$() -{ - // Arrange - $END$ - - // Act - - - // Assert + [Fact] +public void $Fact$() +{ + // Arrange + $END$ + + // Act + + + // Assert } True True diff --git a/Src/FluentAssertions/Common/TypeMemberReflector.cs b/Src/FluentAssertions/Common/TypeMemberReflector.cs index ea65969cdf..2d5f0fce30 100644 --- a/Src/FluentAssertions/Common/TypeMemberReflector.cs +++ b/Src/FluentAssertions/Common/TypeMemberReflector.cs @@ -11,9 +11,6 @@ namespace FluentAssertions.Common; /// internal sealed class TypeMemberReflector { - private const BindingFlags AllInstanceMembersFlag = - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; - public TypeMemberReflector(Type typeToReflect, MemberVisibility visibility) { Properties = LoadProperties(typeToReflect, visibility); @@ -21,149 +18,119 @@ public TypeMemberReflector(Type typeToReflect, MemberVisibility visibility) Members = Properties.Concat(Fields).ToArray(); } - public MemberInfo[] Members { get; } - - public PropertyInfo[] Properties { get; } - - public FieldInfo[] Fields { get; } - +#pragma warning disable MA0051 private static PropertyInfo[] LoadProperties(Type typeToReflect, MemberVisibility visibility) +#pragma warning restore MA0051 { - List query = GetPropertiesFromHierarchy(typeToReflect, visibility); + var collectedProperties = new HashSet(); + var properties = new List(); - return query.ToArray(); - } - - private static List GetPropertiesFromHierarchy(Type typeToReflect, MemberVisibility memberVisibility) - { - bool includeInternal = memberVisibility.HasFlag(MemberVisibility.Internal); - bool includeExplicitlyImplemented = memberVisibility.HasFlag(MemberVisibility.ExplicitlyImplemented); - - return GetMembersFromHierarchy(typeToReflect, type => + // Start with the given type and iterate up the inheritance chain + while (typeToReflect is not null && typeToReflect != typeof(object)) { - return - from p in type.GetProperties(AllInstanceMembersFlag | BindingFlags.DeclaredOnly) - where p.GetMethod is { } getMethod - && (IsPublic(getMethod) || (includeExplicitlyImplemented && IsExplicitlyImplemented(getMethod))) - && (includeInternal || !IsInternal(getMethod)) - && !p.IsIndexer() - orderby IsExplicitImplementation(p) - select p; - }); - } + // Add all properties declared in the current type (including new ones) + PropertyInfo[] allProperties = typeToReflect.GetProperties( + BindingFlags.Instance | BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.NonPublic); - private static bool IsPublic(MethodBase getMethod) => - !getMethod.IsPrivate && !getMethod.IsFamily && !getMethod.IsFamilyAndAssembly; - - private static bool IsExplicitlyImplemented(MethodBase getMethod) => - getMethod.IsPrivate && getMethod.IsFinal; - - private static bool IsInternal(MethodBase getMethod) => - getMethod.IsAssembly || getMethod.IsFamilyOrAssembly; + if (visibility.HasFlag(MemberVisibility.Public) || visibility.HasFlag(MemberVisibility.Internal) || + visibility.HasFlag(MemberVisibility.ExplicitlyImplemented)) + { + foreach (var prop in allProperties) + { + if (!collectedProperties.Contains(prop.Name) && !IsExplicitlyImplemented(prop) && + HasVisibility(visibility, prop)) + { + properties.Add(prop); + collectedProperties.Add(prop.Name); + } + } + } - private static bool IsExplicitImplementation(PropertyInfo property) - { - return property.GetMethod!.IsPrivate && - property.SetMethod?.IsPrivate != false && - property.Name.Contains('.', StringComparison.Ordinal); - } + if (visibility.HasFlag(MemberVisibility.ExplicitlyImplemented)) + { + foreach (var prop in allProperties) + { + if (IsExplicitlyImplemented(prop)) + { + var name = prop.Name.Split('.').Last(); + + if (!collectedProperties.Contains(name)) + { + properties.Add(prop); + collectedProperties.Add(name); + } + } + } + } - private static FieldInfo[] LoadFields(Type typeToReflect, MemberVisibility visibility) - { - List query = GetFieldsFromHierarchy(typeToReflect, visibility); + if (visibility.HasFlag(MemberVisibility.DefaultInterfaceProperties) || typeToReflect.IsInterface) + { + // Add explicitly implemented interface properties (not included above) + var interfaces = typeToReflect.GetInterfaces(); - return query.ToArray(); - } + foreach (var iface in interfaces) + { + foreach (var prop in iface.GetProperties()) + { + if (!collectedProperties.Contains(prop.Name) && (!prop.GetMethod!.IsAbstract || typeToReflect.IsInterface)) + { + properties.Add(prop); + collectedProperties.Add(prop.Name); + } + } + } + } - private static List GetFieldsFromHierarchy(Type typeToReflect, MemberVisibility memberVisibility) - { - bool includeInternal = memberVisibility.HasFlag(MemberVisibility.Internal); + // Move to the base type + typeToReflect = typeToReflect.BaseType; + } - return GetMembersFromHierarchy(typeToReflect, type => - { - return type - .GetFields(AllInstanceMembersFlag) - .Where(field => IsPublic(field)) - .Where(field => includeInternal || !IsInternal(field)); - }); + return properties.Where(x => !x.IsIndexer()).ToArray(); } - private static bool IsPublic(FieldInfo field) => - !field.IsPrivate && !field.IsFamily && !field.IsFamilyAndAssembly; - - private static bool IsInternal(FieldInfo field) + private static bool IsExplicitlyImplemented(PropertyInfo prop) { - return field.IsAssembly || field.IsFamilyOrAssembly; + return prop.Name.Contains('.', StringComparison.OrdinalIgnoreCase); } - private static List GetMembersFromHierarchy( - Type typeToReflect, - Func> getMembers) - where TMemberInfo : MemberInfo - { - if (typeToReflect.IsInterface) - { - return GetInterfaceMembers(typeToReflect, getMembers); - } - - return GetClassMembers(typeToReflect, getMembers); - } + private static bool HasVisibility(MemberVisibility visibility, PropertyInfo prop) => + (visibility.HasFlag(MemberVisibility.Public) && prop.GetMethod?.IsPublic is true) || + (visibility.HasFlag(MemberVisibility.Internal) && (prop.GetMethod?.IsAssembly is true || prop.GetMethod?.IsFamilyOrAssembly is true)); - private static List GetInterfaceMembers(Type typeToReflect, - Func> getMembers) - where TMemberInfo : MemberInfo + private static FieldInfo[] LoadFields(Type typeToReflect, MemberVisibility visibility) { - List members = new(); + var collectedFields = new HashSet(); + var fields = new List(); - var considered = new List(); - var queue = new Queue(); - considered.Add(typeToReflect); - queue.Enqueue(typeToReflect); - - while (queue.Count > 0) + while (typeToReflect is not null && typeToReflect != typeof(object)) { - Type subType = queue.Dequeue(); + // Add all properties declared in the current type (including new ones) + FieldInfo[] files = typeToReflect.GetFields( + BindingFlags.Instance | BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.NonPublic); - foreach (Type subInterface in subType.GetInterfaces()) + foreach (var field in files) { - if (considered.Contains(subInterface)) + if (!collectedFields.Contains(field.Name) && HasVisibility(visibility, field)) { - continue; + fields.Add(field); + collectedFields.Add(field.Name); } - - considered.Add(subInterface); - queue.Enqueue(subInterface); } - IEnumerable typeMembers = getMembers(subType); - - IEnumerable newPropertyInfos = typeMembers.Where(x => !members.Contains(x)); - - members.InsertRange(0, newPropertyInfos); + // Move to the base type + typeToReflect = typeToReflect.BaseType; } - return members; + return fields.ToArray(); } - private static List GetClassMembers(Type typeToReflect, - Func> getMembers) - where TMemberInfo : MemberInfo - { - List members = new(); + private static bool HasVisibility(MemberVisibility visibility, FieldInfo field) => + (visibility.HasFlag(MemberVisibility.Public) && field.IsPublic) || + (visibility.HasFlag(MemberVisibility.Internal) && (field.IsAssembly || field.IsFamilyOrAssembly)); - while (typeToReflect != null) - { - foreach (var memberInfo in getMembers(typeToReflect)) - { - if (members.TrueForAll(mi => mi.Name != memberInfo.Name)) - { - members.Add(memberInfo); - } - } + public MemberInfo[] Members { get; } - typeToReflect = typeToReflect.BaseType; - } + public PropertyInfo[] Properties { get; } - return members; - } + public FieldInfo[] Fields { get; } } diff --git a/Src/FluentAssertions/Equivalency/Matching/MustMatchByNameRule.cs b/Src/FluentAssertions/Equivalency/Matching/MustMatchByNameRule.cs index 62a68d4fb9..52c2cacff2 100644 --- a/Src/FluentAssertions/Equivalency/Matching/MustMatchByNameRule.cs +++ b/Src/FluentAssertions/Equivalency/Matching/MustMatchByNameRule.cs @@ -17,7 +17,7 @@ public IMember Match(IMember expectedMember, object subject, INode parent, IEqui { PropertyInfo propertyInfo = subject.GetType().FindProperty( expectedMember.Name, - options.IncludedProperties | MemberVisibility.ExplicitlyImplemented); + options.IncludedProperties | MemberVisibility.ExplicitlyImplemented | MemberVisibility.DefaultInterfaceProperties); subjectMember = propertyInfo is not null && !propertyInfo.IsIndexer() ? new Property(propertyInfo, parent) : null; } diff --git a/Src/FluentAssertions/Equivalency/MemberVisibility.cs b/Src/FluentAssertions/Equivalency/MemberVisibility.cs index e64b798a81..a0eddebf20 100644 --- a/Src/FluentAssertions/Equivalency/MemberVisibility.cs +++ b/Src/FluentAssertions/Equivalency/MemberVisibility.cs @@ -12,5 +12,6 @@ public enum MemberVisibility None = 0, Internal = 1, Public = 2, - ExplicitlyImplemented = 4 + ExplicitlyImplemented = 4, + DefaultInterfaceProperties = 8 } diff --git a/Src/FluentAssertions/Equivalency/Selection/AllPropertiesSelectionRule.cs b/Src/FluentAssertions/Equivalency/Selection/AllPropertiesSelectionRule.cs index ba61049c62..ae647cd9ad 100644 --- a/Src/FluentAssertions/Equivalency/Selection/AllPropertiesSelectionRule.cs +++ b/Src/FluentAssertions/Equivalency/Selection/AllPropertiesSelectionRule.cs @@ -14,8 +14,11 @@ internal class AllPropertiesSelectionRule : IMemberSelectionRule public IEnumerable SelectMembers(INode currentNode, IEnumerable selectedMembers, MemberSelectionContext context) { + MemberVisibility visibility = context.IncludedProperties | MemberVisibility.ExplicitlyImplemented | + MemberVisibility.DefaultInterfaceProperties; + IEnumerable selectedProperties = context.Type - .GetProperties(context.IncludedProperties) + .GetProperties(visibility) .Select(info => new Property(context.Type, info, currentNode)); return selectedMembers.Union(selectedProperties).ToList(); diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt index 89da67fff2..bc72e1fbec 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt @@ -975,6 +975,7 @@ namespace FluentAssertions.Equivalency Internal = 1, Public = 2, ExplicitlyImplemented = 4, + DefaultInterfaceProperties = 8, } public class NestedExclusionOptionBuilder { @@ -2802,4 +2803,4 @@ namespace FluentAssertions.Xml public bool CanHandle(object value) { } public void Format(object value, FluentAssertions.Formatting.FormattedObjectGraph formattedGraph, FluentAssertions.Formatting.FormattingContext context, FluentAssertions.Formatting.FormatChild formatChild) { } } -} +} \ No newline at end of file diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt index 0db0cc0251..5adccdf720 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt @@ -988,6 +988,7 @@ namespace FluentAssertions.Equivalency Internal = 1, Public = 2, ExplicitlyImplemented = 4, + DefaultInterfaceProperties = 8, } public class NestedExclusionOptionBuilder { @@ -2932,4 +2933,4 @@ namespace FluentAssertions.Xml public bool CanHandle(object value) { } public void Format(object value, FluentAssertions.Formatting.FormattedObjectGraph formattedGraph, FluentAssertions.Formatting.FormattingContext context, FluentAssertions.Formatting.FormatChild formatChild) { } } -} +} \ No newline at end of file diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp2.1.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp2.1.verified.txt index e76973c182..3df6aee5fb 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp2.1.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp2.1.verified.txt @@ -975,6 +975,7 @@ namespace FluentAssertions.Equivalency Internal = 1, Public = 2, ExplicitlyImplemented = 4, + DefaultInterfaceProperties = 8, } public class NestedExclusionOptionBuilder { diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp3.0.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp3.0.verified.txt index e76973c182..3df6aee5fb 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp3.0.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp3.0.verified.txt @@ -975,6 +975,7 @@ namespace FluentAssertions.Equivalency Internal = 1, Public = 2, ExplicitlyImplemented = 4, + DefaultInterfaceProperties = 8, } public class NestedExclusionOptionBuilder { diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt index d14c1711b3..7995d53641 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt @@ -968,6 +968,7 @@ namespace FluentAssertions.Equivalency Internal = 1, Public = 2, ExplicitlyImplemented = 4, + DefaultInterfaceProperties = 8, } public class NestedExclusionOptionBuilder { @@ -2753,4 +2754,4 @@ namespace FluentAssertions.Xml public bool CanHandle(object value) { } public void Format(object value, FluentAssertions.Formatting.FormattedObjectGraph formattedGraph, FluentAssertions.Formatting.FormattingContext context, FluentAssertions.Formatting.FormatChild formatChild) { } } -} +} \ No newline at end of file diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt index 99eb81a1e3..3df6aee5fb 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt @@ -975,6 +975,7 @@ namespace FluentAssertions.Equivalency Internal = 1, Public = 2, ExplicitlyImplemented = 4, + DefaultInterfaceProperties = 8, } public class NestedExclusionOptionBuilder { @@ -2804,4 +2805,4 @@ namespace FluentAssertions.Xml public bool CanHandle(object value) { } public void Format(object value, FluentAssertions.Formatting.FormattedObjectGraph formattedGraph, FluentAssertions.Formatting.FormattingContext context, FluentAssertions.Formatting.FormatChild formatChild) { } } -} +} \ No newline at end of file diff --git a/Tests/FluentAssertions.Equivalency.Specs/FluentAssertions.Equivalency.Specs.csproj b/Tests/FluentAssertions.Equivalency.Specs/FluentAssertions.Equivalency.Specs.csproj index 92f192b555..834982361b 100644 --- a/Tests/FluentAssertions.Equivalency.Specs/FluentAssertions.Equivalency.Specs.csproj +++ b/Tests/FluentAssertions.Equivalency.Specs/FluentAssertions.Equivalency.Specs.csproj @@ -7,6 +7,7 @@ false $(NoWarn),IDE0052,1573,1591,1712 full + 12 diff --git a/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.cs b/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.cs index 0b02f9b911..24a48f9a24 100644 --- a/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.cs +++ b/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.cs @@ -301,6 +301,36 @@ internal class BaseClassPointingToClassWithoutProperties internal class ClassWithoutProperty { } + +#if NETCOREAPP3_0_OR_GREATER + [Fact] + public void Will_include_default_interface_properties_in_the_comparison() + { + var lista = new List + { + new Test { Name = "Test" } + }; + + List listb = new() + { + new Test { Name = "Test" } + }; + + lista.Should().BeEquivalentTo(listb); + } + + private class Test : ITest + { + public string Name { get; set; } + } + + private interface ITest + { + public string Name { get; } + + public int NameLength => Name.Length; + } +#endif } public class Including @@ -607,6 +637,63 @@ public void When_both_field_and_properties_are_configured_for_inclusion_both_sho // Assert act.Should().Throw().Which.Message.Should().Contain("Field1").And.Contain("Property1"); } + +#if NETCOREAPP3_0_OR_GREATER + [Fact] + public void Can_include_a_default_interface_property_using_an_expression() + { + // Arrange + IHaveDefaultProperty subject = new ClassReceivedDefaultInterfaceProperty + { + NormalProperty = "Value" + }; + + IHaveDefaultProperty expectation = new ClassReceivedDefaultInterfaceProperty + { + NormalProperty = "Another Value" + }; + + // Act + var act = () => subject.Should().BeEquivalentTo(expectation, x => x.Including(p => p.DefaultProperty)); + + // Assert + act.Should().Throw().WithMessage("Expected property subject.DefaultProperty to be 13, but found 5.*"); + } + + [Fact] + public void Can_include_a_default_interface_property_using_a_name() + { + // Arrange + IHaveDefaultProperty subject = new ClassReceivedDefaultInterfaceProperty + { + NormalProperty = "Value" + }; + + IHaveDefaultProperty expectation = new ClassReceivedDefaultInterfaceProperty + { + NormalProperty = "Another Value" + }; + + // Act + var act = () => subject.Should().BeEquivalentTo(expectation, + x => x.Including(p => p.Name.Contains("DefaultProperty"))); + + // Assert + act.Should().Throw().WithMessage("Expected property subject.DefaultProperty to be 13, but found 5.*"); + } + + private class ClassReceivedDefaultInterfaceProperty : IHaveDefaultProperty + { + public string NormalProperty { get; set; } + } + + private interface IHaveDefaultProperty + { + string NormalProperty { get; set; } + + int DefaultProperty => NormalProperty.Length; + } +#endif } public class Excluding @@ -1236,6 +1323,65 @@ public void When_excluding_virtual_or_abstract_property_exclusion_works_properly .Excluding(o => o.VirtualProperty) .Excluding(o => o.DerivedProperty2)); } + +#if NETCOREAPP3_0_OR_GREATER + [Fact] + public void Can_exclude_a_default_interface_property_using_an_expression() + { + // Arrange + IHaveDefaultProperty subject = new ClassReceivedDefaultInterfaceProperty + { + NormalProperty = "Value" + }; + + IHaveDefaultProperty expectation = new ClassReceivedDefaultInterfaceProperty + { + NormalProperty = "Another Value" + }; + + // Act + var act = () => subject.Should().BeEquivalentTo(expectation, + x => x.Excluding(p => p.DefaultProperty)); + + // Assert + act.Should().Throw().Which.Message.Should().NotContain("subject.DefaultProperty"); + } + + [Fact] + public void Can_exclude_a_default_interface_property_using_a_name() + { + // Arrange + IHaveDefaultProperty subject = new ClassReceivedDefaultInterfaceProperty + { + NormalProperty = "Value" + }; + + IHaveDefaultProperty expectation = new ClassReceivedDefaultInterfaceProperty + { + NormalProperty = "Another Value" + }; + + // Act + var act = () => subject.Should().BeEquivalentTo(expectation, + x => x.Excluding(info => info.Name.Contains("DefaultProperty"))); + + // Assert + act.Should().Throw().Which.Message.Should().NotContain("subject.DefaultProperty"); + } + + private class ClassReceivedDefaultInterfaceProperty : IHaveDefaultProperty + { + public string NormalProperty { get; set; } + } + + private interface IHaveDefaultProperty + { + string NormalProperty { get; set; } + + int DefaultProperty => NormalProperty.Length; + } +#endif + } public class Accessibility @@ -1918,7 +2064,8 @@ public void Explicitly_implemented_subject_properties_are_ignored_if_a_normal_pr } [Fact] - public void Explicitly_implemented_read_only_subject_properties_are_ignored_if_a_normal_property_exists_with_the_same_name() + public void + Explicitly_implemented_read_only_subject_properties_are_ignored_if_a_normal_property_exists_with_the_same_name() { // Arrange IReadOnlyVehicle subject = new ExplicitReadOnlyVehicle(explicitValue: 1) @@ -1982,6 +2129,34 @@ public void Explicitly_implemented_subject_properties_are_ignored_if_only_fields .ExcludingMissingMembers()); } + [Fact] + public void Normal_properties_have_priority_over_explicitly_implemented_properties() + { + var instance = new MyClass + { + MyError = 42, + }; + + var other = new MyClass + { + MyError = 42, + }; + + instance.Should().BeEquivalentTo(other); + } + + private class MyClass : Exception, IMyInterface + { + public int MyError { get; set; } + + int IMyInterface.Message => MyError; + } + + private interface IMyInterface + { + int Message { get; } + } + [Fact] public void Excluding_missing_members_does_not_affect_how_explicitly_implemented_subject_properties_are_dealt_with() { @@ -2547,7 +2722,8 @@ public void Only_ignore_non_browsable_matching_members() }; // Act - Action action = () => subject.Should().BeEquivalentTo(expectation, config => config.IgnoringNonBrowsableMembersOnSubject()); + Action action = () => + subject.Should().BeEquivalentTo(expectation, config => config.IgnoringNonBrowsableMembersOnSubject()); // Assert action.Should().Throw(); diff --git a/Tests/FluentAssertions.Specs/Types/TypeExtensionsSpecs.cs b/Tests/FluentAssertions.Specs/Common/TypeExtensionsSpecs.cs similarity index 99% rename from Tests/FluentAssertions.Specs/Types/TypeExtensionsSpecs.cs rename to Tests/FluentAssertions.Specs/Common/TypeExtensionsSpecs.cs index 73ef4247ed..a1ed9b664c 100644 --- a/Tests/FluentAssertions.Specs/Types/TypeExtensionsSpecs.cs +++ b/Tests/FluentAssertions.Specs/Common/TypeExtensionsSpecs.cs @@ -7,7 +7,7 @@ using FluentAssertions.Common; using Xunit; -namespace FluentAssertions.Specs.Types; +namespace FluentAssertions.Specs.Common; public class TypeExtensionsSpecs { diff --git a/Tests/FluentAssertions.Specs/Common/TypeMemberReflectorSpecs.cs b/Tests/FluentAssertions.Specs/Common/TypeMemberReflectorSpecs.cs new file mode 100644 index 0000000000..1871ea1acb --- /dev/null +++ b/Tests/FluentAssertions.Specs/Common/TypeMemberReflectorSpecs.cs @@ -0,0 +1,249 @@ +#if NETCOREAPP3_0_OR_GREATER +using System.Globalization; +using FluentAssertions.Common; +using FluentAssertions.Equivalency; +using JetBrains.Annotations; +using Xunit; + +namespace FluentAssertions.Specs.Common; + +public class TypeMemberReflectorSpecs +{ + public class GetProperties + { + [Fact] + public void Can_get_all_public_explicit_and_default_interface_properties() + { + // Act + var reflector = new TypeMemberReflector(typeof(SuperClass), + MemberVisibility.Public | MemberVisibility.ExplicitlyImplemented | MemberVisibility.DefaultInterfaceProperties); + + // Assert + reflector.Properties.Should().BeEquivalentTo(new[] + { + new { Name = "NormalProperty", PropertyType = typeof(string) }, + new { Name = "NewProperty", PropertyType = typeof(int) }, + new { Name = "InterfaceProperty", PropertyType = typeof(string) }, + new + { + Name = $"{typeof(IInterfaceWithSingleProperty).FullName!.Replace("+", ".")}.ExplicitlyImplementedProperty", + PropertyType = typeof(string) + }, + new { Name = "DefaultProperty", PropertyType = typeof(string) } + }); + } + + [Fact] + public void Can_get_all_properties_from_an_interface() + { + // Act + var reflector = new TypeMemberReflector(typeof(IInterfaceWithDefaultProperty), + MemberVisibility.Public); + + // Assert + reflector.Properties.Should().BeEquivalentTo(new[] + { + new { Name = "InterfaceProperty", PropertyType = typeof(string) }, + new { Name = "ExplicitlyImplementedProperty", PropertyType = typeof(string), }, + new { Name = "DefaultProperty", PropertyType = typeof(string) } + }); + } + + [Fact] + public void Can_get_normal_public_properties() + { + // Act + var reflector = new TypeMemberReflector(typeof(SuperClass), MemberVisibility.Public); + + // Assert + reflector.Properties.Should().BeEquivalentTo(new[] + { + new { Name = "NormalProperty", PropertyType = typeof(string) }, + new { Name = "NewProperty", PropertyType = typeof(int) }, + new { Name = "InterfaceProperty", PropertyType = typeof(string) }, + }); + } + + [Fact] + public void Can_get_explicit_properties_only() + { + // Act + var reflector = new TypeMemberReflector(typeof(SuperClass), MemberVisibility.ExplicitlyImplemented); + + // Assert + reflector.Properties.Should().BeEquivalentTo(new[] + { + new + { + Name = $"{typeof(IInterfaceWithSingleProperty).FullName!.Replace("+", ".")}.ExplicitlyImplementedProperty", + PropertyType = typeof(string) + }, + }); + } + + [Fact] + public void Prefers_normal_property_over_explicitly_implemented_one() + { + // Act + var reflector = new TypeMemberReflector(typeof(ClassWithExplicitAndNormalProperty), + MemberVisibility.Public | MemberVisibility.ExplicitlyImplemented); + + // Assert + reflector.Properties.Should().BeEquivalentTo(new[] + { + new + { + Name = "ExplicitlyImplementedProperty", + PropertyType = typeof(int) + }, + }); + } + + [Fact] + public void Can_get_default_interface_properties_only() + { + // Act + var reflector = new TypeMemberReflector(typeof(SuperClass), MemberVisibility.DefaultInterfaceProperties); + + // Assert + reflector.Properties.Should().BeEquivalentTo(new[] + { + new { Name = "DefaultProperty", PropertyType = typeof(string) }, + }); + } + + [Fact] + public void Can_get_internal_properties() + { + // Act + var reflector = new TypeMemberReflector(typeof(SuperClass), MemberVisibility.Internal); + + // Assert + reflector.Properties.Should().BeEquivalentTo(new[] + { + new { Name = "InternalProperty", PropertyType = typeof(bool) }, + new { Name = "InternalProtectedProperty", PropertyType = typeof(bool) } + }); + } + + [Fact] + public void Will_ignore_indexers() + { + // Act + var reflector = new TypeMemberReflector(typeof(ClassWithIndexer), MemberVisibility.Public); + + // Assert + reflector.Properties.Should().BeEquivalentTo(new[] + { + new { Name = "Foo", PropertyType = typeof(object) } + }); + } + } + + public class GetFields + { + [Fact] + public void Can_find_public_fields() + { + // Act + var reflector = new TypeMemberReflector(typeof(SuperClass), MemberVisibility.Public); + + // Assert + reflector.Fields.Should().BeEquivalentTo(new[] + { + new { Name = "NormalField", FieldType = typeof(string) }, + }); + } + + [Fact] + public void Can_find_internal_fields() + { + // Act + var reflector = new TypeMemberReflector(typeof(SuperClass), MemberVisibility.Internal); + + // Assert + reflector.Fields.Should().BeEquivalentTo(new[] + { + new { Name = "InternalField", FieldType = typeof(string) }, + new { Name = "ProtectedInternalField", FieldType = typeof(string) }, + }); + } + + [Fact] + public void Can_find_all_fields() + { + // Act + var reflector = new TypeMemberReflector(typeof(SuperClass), MemberVisibility.Internal | MemberVisibility.Public); + + // Assert + reflector.Fields.Should().BeEquivalentTo(new[] + { + new { Name = "NormalField", FieldType = typeof(string) }, + new { Name = "InternalField", FieldType = typeof(string) }, + new { Name = "ProtectedInternalField", FieldType = typeof(string) } + }); + } + } + + private class SuperClass : BaseClass, IInterfaceWithDefaultProperty + { + public string NormalProperty { get; set; } + + public new int NewProperty { get; set; } + + internal bool InternalProperty { get; set; } + + protected internal bool InternalProtectedProperty { get; set; } + + string IInterfaceWithSingleProperty.ExplicitlyImplementedProperty { get; set; } + + public string InterfaceProperty { get; set; } + +#pragma warning disable CS0649 // Field is never assigned to, and will always have its default value + public string NormalField; + + internal string InternalField; + + protected internal string ProtectedInternalField; +#pragma warning restore CS0649 // Field is never assigned to, and will always have its default value + } + + private class ClassWithExplicitAndNormalProperty : IInterfaceWithSingleProperty + { + string IInterfaceWithSingleProperty.ExplicitlyImplementedProperty { get; set; } + + [UsedImplicitly] + public int ExplicitlyImplementedProperty { get; set; } + } + + private class BaseClass + { + [UsedImplicitly] + public string NewProperty { get; set; } + } + + private interface IInterfaceWithDefaultProperty : IInterfaceWithSingleProperty + { + [UsedImplicitly] + string InterfaceProperty { get; set; } + + [UsedImplicitly] + string DefaultProperty => "Default"; + } + + private interface IInterfaceWithSingleProperty + { + [UsedImplicitly] + string ExplicitlyImplementedProperty { get; set; } + } + + private class ClassWithIndexer + { + [UsedImplicitly] + public object Foo { get; set; } + + public string this[int n] => n.ToString(CultureInfo.InvariantCulture); + } +} + +#endif diff --git a/Tests/FluentAssertions.Specs/FluentAssertions.Specs.csproj b/Tests/FluentAssertions.Specs/FluentAssertions.Specs.csproj index d5c5a52b90..b2b56399a7 100644 --- a/Tests/FluentAssertions.Specs/FluentAssertions.Specs.csproj +++ b/Tests/FluentAssertions.Specs/FluentAssertions.Specs.csproj @@ -7,6 +7,7 @@ false $(NoWarn),IDE0052,1573,1591,1712,CS8002 full + 12