From 51c118fb5ccd271cc6e99eaf8c270a6b657509f0 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.cs | 1 + Build/_build.csproj.DotSettings | 5 +- FluentAssertions.sln.DotSettings | 33 ++-- Src/FluentAssertions/Common/TypeExtensions.cs | 29 +-- .../Common/TypeMemberReflector.cs | 169 ---------------- .../Matching/MustMatchByNameRule.cs | 2 +- .../Equivalency/MemberVisibility.cs | 3 +- .../Equivalency/MemberVisibilityExtensions.cs | 38 ++++ .../Selection/AllFieldsSelectionRule.cs | 4 +- .../Selection/AllPropertiesSelectionRule.cs | 7 +- .../IncludeMemberByPathSelectionRule.cs | 3 +- .../IncludeMemberByPredicateSelectionRule.cs | 5 +- Src/FluentAssertions/FluentAssertions.csproj | 10 +- .../Formatting/DefaultValueFormatter.cs | 4 +- .../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 +- .../FluentAssertions.Specs.csproj | 1 + docs/_pages/releases.md | 7 +- 26 files changed, 368 insertions(+), 307 deletions(-) delete mode 100644 Src/FluentAssertions/Common/TypeMemberReflector.cs create mode 100644 Src/FluentAssertions/Equivalency/MemberVisibilityExtensions.cs rename Tests/FluentAssertions.Specs/{Types => Common}/TypeExtensionsSpecs.cs (99%) 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.cs b/Build/Build.cs index 261ee82390..96e3c97575 100644 --- a/Build/Build.cs +++ b/Build/Build.cs @@ -259,6 +259,7 @@ void ReportTestOutcome(params string[] globFilters) ReportTypes.lcov, ReportTypes.HtmlInline_AzurePipelines_Dark) .AddFileFilters("-*.g.cs") + .AddFileFilters("-**/.nuget/**") .SetAssemblyFilters("+FluentAssertions")); string link = TestResultsDirectory / "reports" / "index.html"; 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/TypeExtensions.cs b/Src/FluentAssertions/Common/TypeExtensions.cs index a84af752ad..5999054d7a 100644 --- a/Src/FluentAssertions/Common/TypeExtensions.cs +++ b/Src/FluentAssertions/Common/TypeExtensions.cs @@ -7,6 +7,7 @@ using System.Runtime.CompilerServices; using System.Text; using FluentAssertions.Equivalency; +using Reflectify; namespace FluentAssertions.Common; @@ -22,9 +23,6 @@ internal static class TypeExtensions private static readonly ConcurrentDictionary TypeIsRecordCache = new(); private static readonly ConcurrentDictionary TypeIsCompilerGeneratedCache = new(); - private static readonly ConcurrentDictionary<(Type Type, MemberVisibility Visibility), TypeMemberReflector> - TypeMemberReflectorsCache = new(); - public static bool IsDecoratedWith(this Type type) where TAttribute : Attribute { @@ -184,7 +182,7 @@ public static bool OverridesEquals(this Type type) /// public static PropertyInfo FindProperty(this Type type, string propertyName, MemberVisibility memberVisibility) { - var properties = type.GetProperties(memberVisibility); + var properties = type.GetProperties(memberVisibility.ToMemberKind()); return Array.Find(properties, p => p.Name == propertyName || p.Name.EndsWith("." + propertyName, StringComparison.Ordinal)); @@ -198,32 +196,11 @@ public static PropertyInfo FindProperty(this Type type, string propertyName, Mem /// public static FieldInfo FindField(this Type type, string fieldName, MemberVisibility memberVisibility) { - var fields = type.GetFields(memberVisibility); + var fields = type.GetFields(memberVisibility.ToMemberKind()); return Array.Find(fields, p => p.Name == fieldName); } - public static MemberInfo[] GetMembers(this Type typeToReflect, MemberVisibility visibility) - { - return GetTypeReflectorFor(typeToReflect, visibility).Members; - } - - public static PropertyInfo[] GetProperties(this Type typeToReflect, MemberVisibility visibility) - { - return GetTypeReflectorFor(typeToReflect, visibility).Properties; - } - - public static FieldInfo[] GetFields(this Type typeToReflect, MemberVisibility visibility) - { - return GetTypeReflectorFor(typeToReflect, visibility).Fields; - } - - private static TypeMemberReflector GetTypeReflectorFor(Type typeToReflect, MemberVisibility visibility) - { - return TypeMemberReflectorsCache.GetOrAdd((typeToReflect, visibility), - static key => new TypeMemberReflector(key.Type, key.Visibility)); - } - /// /// Check if the type is declared as abstract. /// diff --git a/Src/FluentAssertions/Common/TypeMemberReflector.cs b/Src/FluentAssertions/Common/TypeMemberReflector.cs deleted file mode 100644 index ea65969cdf..0000000000 --- a/Src/FluentAssertions/Common/TypeMemberReflector.cs +++ /dev/null @@ -1,169 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using FluentAssertions.Equivalency; - -namespace FluentAssertions.Common; - -/// -/// Helper class to get all the public and internal fields and properties from a type. -/// -internal sealed class TypeMemberReflector -{ - private const BindingFlags AllInstanceMembersFlag = - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; - - public TypeMemberReflector(Type typeToReflect, MemberVisibility visibility) - { - Properties = LoadProperties(typeToReflect, visibility); - Fields = LoadFields(typeToReflect, visibility); - Members = Properties.Concat(Fields).ToArray(); - } - - public MemberInfo[] Members { get; } - - public PropertyInfo[] Properties { get; } - - public FieldInfo[] Fields { get; } - - private static PropertyInfo[] LoadProperties(Type typeToReflect, MemberVisibility visibility) - { - List query = GetPropertiesFromHierarchy(typeToReflect, visibility); - - 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 => - { - 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; - }); - } - - 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; - - private static bool IsExplicitImplementation(PropertyInfo property) - { - return property.GetMethod!.IsPrivate && - property.SetMethod?.IsPrivate != false && - property.Name.Contains('.', StringComparison.Ordinal); - } - - private static FieldInfo[] LoadFields(Type typeToReflect, MemberVisibility visibility) - { - List query = GetFieldsFromHierarchy(typeToReflect, visibility); - - return query.ToArray(); - } - - private static List GetFieldsFromHierarchy(Type typeToReflect, MemberVisibility memberVisibility) - { - bool includeInternal = memberVisibility.HasFlag(MemberVisibility.Internal); - - return GetMembersFromHierarchy(typeToReflect, type => - { - return type - .GetFields(AllInstanceMembersFlag) - .Where(field => IsPublic(field)) - .Where(field => includeInternal || !IsInternal(field)); - }); - } - - private static bool IsPublic(FieldInfo field) => - !field.IsPrivate && !field.IsFamily && !field.IsFamilyAndAssembly; - - private static bool IsInternal(FieldInfo field) - { - return field.IsAssembly || field.IsFamilyOrAssembly; - } - - private static List GetMembersFromHierarchy( - Type typeToReflect, - Func> getMembers) - where TMemberInfo : MemberInfo - { - if (typeToReflect.IsInterface) - { - return GetInterfaceMembers(typeToReflect, getMembers); - } - - return GetClassMembers(typeToReflect, getMembers); - } - - private static List GetInterfaceMembers(Type typeToReflect, - Func> getMembers) - where TMemberInfo : MemberInfo - { - List members = new(); - - var considered = new List(); - var queue = new Queue(); - considered.Add(typeToReflect); - queue.Enqueue(typeToReflect); - - while (queue.Count > 0) - { - Type subType = queue.Dequeue(); - - foreach (Type subInterface in subType.GetInterfaces()) - { - if (considered.Contains(subInterface)) - { - continue; - } - - considered.Add(subInterface); - queue.Enqueue(subInterface); - } - - IEnumerable typeMembers = getMembers(subType); - - IEnumerable newPropertyInfos = typeMembers.Where(x => !members.Contains(x)); - - members.InsertRange(0, newPropertyInfos); - } - - return members; - } - - private static List GetClassMembers(Type typeToReflect, - Func> getMembers) - where TMemberInfo : MemberInfo - { - List members = new(); - - while (typeToReflect != null) - { - foreach (var memberInfo in getMembers(typeToReflect)) - { - if (members.TrueForAll(mi => mi.Name != memberInfo.Name)) - { - members.Add(memberInfo); - } - } - - typeToReflect = typeToReflect.BaseType; - } - - return members; - } -} 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/MemberVisibilityExtensions.cs b/Src/FluentAssertions/Equivalency/MemberVisibilityExtensions.cs new file mode 100644 index 0000000000..df035de556 --- /dev/null +++ b/Src/FluentAssertions/Equivalency/MemberVisibilityExtensions.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Concurrent; +using Reflectify; + +namespace FluentAssertions.Equivalency; + +internal static class MemberVisibilityExtensions +{ + private static readonly ConcurrentDictionary Cache = new(); + + public static MemberKind ToMemberKind(this MemberVisibility visibility) + { + return Cache.GetOrAdd(visibility, v => + { + MemberKind result = MemberKind.None; + + foreach (MemberVisibility flag in Enum.GetValues(typeof(MemberVisibility))) + { + if (v.HasFlag(flag)) + { + var convertedFlag = flag switch + { + MemberVisibility.None => MemberKind.None, + MemberVisibility.Internal => MemberKind.Internal, + MemberVisibility.Public => MemberKind.Public, + MemberVisibility.ExplicitlyImplemented => MemberKind.ExplicitlyImplemented, + MemberVisibility.DefaultInterfaceProperties => MemberKind.DefaultInterfaceProperties, + _ => throw new ArgumentOutOfRangeException(nameof(v), v, null) + }; + + result |= convertedFlag; + } + } + + return result; + }); + } +} diff --git a/Src/FluentAssertions/Equivalency/Selection/AllFieldsSelectionRule.cs b/Src/FluentAssertions/Equivalency/Selection/AllFieldsSelectionRule.cs index fadd165609..505f71cce7 100644 --- a/Src/FluentAssertions/Equivalency/Selection/AllFieldsSelectionRule.cs +++ b/Src/FluentAssertions/Equivalency/Selection/AllFieldsSelectionRule.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using System.Linq; -using FluentAssertions.Common; +using Reflectify; namespace FluentAssertions.Equivalency.Selection; @@ -15,7 +15,7 @@ public IEnumerable SelectMembers(INode currentNode, IEnumerable selectedFields = context.Type - .GetFields(context.IncludedFields) + .GetFields(context.IncludedFields.ToMemberKind()) .Select(info => new Field(info, currentNode)); return selectedMembers.Union(selectedFields).ToList(); diff --git a/Src/FluentAssertions/Equivalency/Selection/AllPropertiesSelectionRule.cs b/Src/FluentAssertions/Equivalency/Selection/AllPropertiesSelectionRule.cs index ba61049c62..803bbbf219 100644 --- a/Src/FluentAssertions/Equivalency/Selection/AllPropertiesSelectionRule.cs +++ b/Src/FluentAssertions/Equivalency/Selection/AllPropertiesSelectionRule.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using System.Linq; -using FluentAssertions.Common; +using Reflectify; namespace FluentAssertions.Equivalency.Selection; @@ -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.ToMemberKind()) .Select(info => new Property(context.Type, info, currentNode)); return selectedMembers.Union(selectedProperties).ToList(); diff --git a/Src/FluentAssertions/Equivalency/Selection/IncludeMemberByPathSelectionRule.cs b/Src/FluentAssertions/Equivalency/Selection/IncludeMemberByPathSelectionRule.cs index 7646069aa2..4ed440b5a5 100644 --- a/Src/FluentAssertions/Equivalency/Selection/IncludeMemberByPathSelectionRule.cs +++ b/Src/FluentAssertions/Equivalency/Selection/IncludeMemberByPathSelectionRule.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Reflection; using FluentAssertions.Common; +using Reflectify; namespace FluentAssertions.Equivalency.Selection; @@ -21,7 +22,7 @@ public IncludeMemberByPathSelectionRule(MemberPath pathToInclude) protected override void AddOrRemoveMembersFrom(List selectedMembers, INode parent, string parentPath, MemberSelectionContext context) { - foreach (MemberInfo memberInfo in context.Type.GetMembers(MemberVisibility.Public | MemberVisibility.Internal)) + foreach (MemberInfo memberInfo in context.Type.GetMembers(MemberKind.Public | MemberKind.Internal)) { var memberPath = new MemberPath(context.Type, memberInfo.DeclaringType, parentPath.Combine(memberInfo.Name)); diff --git a/Src/FluentAssertions/Equivalency/Selection/IncludeMemberByPredicateSelectionRule.cs b/Src/FluentAssertions/Equivalency/Selection/IncludeMemberByPredicateSelectionRule.cs index 97000aad92..ac484eff03 100644 --- a/Src/FluentAssertions/Equivalency/Selection/IncludeMemberByPredicateSelectionRule.cs +++ b/Src/FluentAssertions/Equivalency/Selection/IncludeMemberByPredicateSelectionRule.cs @@ -3,6 +3,7 @@ using System.Linq.Expressions; using System.Reflection; using FluentAssertions.Common; +using Reflectify; namespace FluentAssertions.Equivalency.Selection; @@ -27,8 +28,8 @@ public IEnumerable SelectMembers(INode currentNode, IEnumerable(selectedMembers); - foreach (MemberInfo memberInfo in currentNode.Type.GetMembers(MemberVisibility.Public | - MemberVisibility.Internal)) + foreach (MemberInfo memberInfo in currentNode.Type.GetMembers(MemberKind.Public | + MemberKind.Internal)) { IMember member = MemberFactory.Create(memberInfo, currentNode); diff --git a/Src/FluentAssertions/FluentAssertions.csproj b/Src/FluentAssertions/FluentAssertions.csproj index 48a22d046e..161a1145c1 100644 --- a/Src/FluentAssertions/FluentAssertions.csproj +++ b/Src/FluentAssertions/FluentAssertions.csproj @@ -12,7 +12,7 @@ true true - + Dennis Doomen;Jonas Nyrup @@ -31,7 +31,7 @@ See https://fluentassertions.com/releases/ Copyright Dennis Doomen 2010-$([System.DateTime]::Now.ToString(yyyy)) - + <_Parameter1>FluentAssertions.Specs, PublicKey=00240000048000009400000006020000002400005253413100040000010001002d25ff515c85b13ba08f61d466cff5d80a7f28ba197bbf8796085213e7a3406f970d2a4874932fed35db546e89af2da88c194bf1b7f7ac70de7988c78406f7629c547283061282a825616eb7eb48a9514a7570942936020a9bb37dca9ff60b778309900851575614491c6d25018fadb75828f4c7a17bf2d7dc86e7b6eafc5d8f @@ -53,6 +53,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + @@ -120,5 +124,5 @@ - + diff --git a/Src/FluentAssertions/Formatting/DefaultValueFormatter.cs b/Src/FluentAssertions/Formatting/DefaultValueFormatter.cs index 93775f7c99..28da03d6dc 100644 --- a/Src/FluentAssertions/Formatting/DefaultValueFormatter.cs +++ b/Src/FluentAssertions/Formatting/DefaultValueFormatter.cs @@ -2,7 +2,7 @@ using System.Linq; using System.Reflection; using FluentAssertions.Common; -using FluentAssertions.Equivalency; +using Reflectify; namespace FluentAssertions.Formatting; @@ -56,7 +56,7 @@ public void Format(object value, FormattedObjectGraph formattedGraph, Formatting /// The default is all non-private members. protected virtual MemberInfo[] GetMembers(Type type) { - return type.GetMembers(MemberVisibility.Public); + return type.GetMembers(MemberKind.Public); } private static bool HasCompilerGeneratedToStringImplementation(object value) 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/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 diff --git a/docs/_pages/releases.md b/docs/_pages/releases.md index bab190b1d1..dacd0b0866 100644 --- a/docs/_pages/releases.md +++ b/docs/_pages/releases.md @@ -7,13 +7,18 @@ sidebar: nav: "sidebar" --- +## 6.12.2 + +### Fixes +* Better handling of normal vs explicitly implemented vs default interface properties - [2794](https://github.com/fluentassertions/fluentassertions/pull/2794) + ## 6.12.1 ### Improvements * Improve `BeEmpty()` and `BeNullOrEmpty()` performance for `IEnumerable`, by materializing only the first item - [#2530](https://github.com/fluentassertions/fluentassertions/pull/2530) ### Fixes -* Fixed formatting error when checking nullable `DateTimeOffset` with +* Fixed formatting error when checking nullable `DateTimeOffset` with `BeWithin(...).Before(...)` - [#2312](https://github.com/fluentassertions/fluentassertions/pull/2312) * `BeEquivalentTo` will now find and can map subject properties that are implemented through an explicitly-implemented interface - [#2152](https://github.com/fluentassertions/fluentassertions/pull/2152) * Fixed that the `because` and `becauseArgs` were not passed down the equivalency tree - [#2318](https://github.com/fluentassertions/fluentassertions/pull/2318)