From 7cd0ea1686cf39b4739823bc1cd700a7f6a0d02a Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Mon, 5 Nov 2018 22:17:46 -0500 Subject: [PATCH] Selecting members during object graph assertions now honors the declaring type Fixes #956 --- .../GenericCollectionAssertions.cs | 2 +- .../Common/ExpressionExtensions.cs | 18 ++++-- Src/FluentAssertions/Common/MemberPath.cs | 62 +++++++++++++++++++ .../Common/StringExtensions.cs | 9 --- .../EquivalencyAssertionOptions.cs | 2 +- .../ExcludeMemberByPathSelectionRule.cs | 15 +++-- .../IncludeMemberByPathSelectionRule.cs | 15 +++-- .../Equivalency/Selection/MemberPath.cs | 43 ------------- Tests/Shared.Specs/BasicEquivalencySpecs.cs | 45 ++++++++++++++ 9 files changed, 141 insertions(+), 70 deletions(-) create mode 100644 Src/FluentAssertions/Common/MemberPath.cs delete mode 100644 Src/FluentAssertions/Equivalency/Selection/MemberPath.cs diff --git a/Src/FluentAssertions/Collections/GenericCollectionAssertions.cs b/Src/FluentAssertions/Collections/GenericCollectionAssertions.cs index 12e9a60e5c..0edd8ab971 100644 --- a/Src/FluentAssertions/Collections/GenericCollectionAssertions.cs +++ b/Src/FluentAssertions/Collections/GenericCollectionAssertions.cs @@ -254,7 +254,7 @@ public AndConstraint> OnlyHaveUniqueItems(E ? unordered.OrderBy(keySelector, comparer) : unordered.OrderByDescending(keySelector, comparer); - var orderString = propertyExpression.GetMemberPath(); + string orderString = propertyExpression.GetMemberPath().ToString(); orderString = orderString == "\"\"" ? string.Empty : " by " + orderString; Execute.Assertion diff --git a/Src/FluentAssertions/Common/ExpressionExtensions.cs b/Src/FluentAssertions/Common/ExpressionExtensions.cs index eb2cd38cb2..c99b0f552a 100644 --- a/Src/FluentAssertions/Common/ExpressionExtensions.cs +++ b/Src/FluentAssertions/Common/ExpressionExtensions.cs @@ -5,10 +5,11 @@ using System.Reflection; using FluentAssertions.Equivalency; +using FluentAssertions.Equivalency.Selection; namespace FluentAssertions.Common { - public static class ExpressionExtensions + internal static class ExpressionExtensions { public static SelectedMemberInfo GetSelectedMemberInfo(this Expression> expression) { @@ -79,9 +80,12 @@ public static class ExpressionExtensions } /// - /// Gets a dotted path of property names representing the property expression. E.g. Parent.Child.Sibling.Name. + /// Gets a dotted path of property names representing the property expression, including the declaring type. /// - public static string GetMemberPath( + /// + /// E.g. Parent.Child.Sibling.Name. + /// + public static MemberPath GetMemberPath( this Expression> expression) { if (expression == null) @@ -90,6 +94,7 @@ public static class ExpressionExtensions } var segments = new List(); + var declaringTypes = new List(); Expression node = expression; var unsupportedExpressionMessage = $"Expression <{expression.Body}> cannot be used to select a member."; @@ -113,6 +118,7 @@ public static class ExpressionExtensions node = memberExpression.Expression; segments.Add(memberExpression.Member.Name); + declaringTypes.Add(memberExpression.Member.DeclaringType); break; case ExpressionType.ArrayIndex: @@ -144,9 +150,13 @@ public static class ExpressionExtensions } } + // If any members were accessed in the expression, the first one found is the last member. + Type declaringType = declaringTypes.FirstOrDefault( ) ?? typeof(TDeclaringType); + string[] reversedSegments = segments.AsEnumerable().Reverse().ToArray(); string segmentPath = string.Join(".", reversedSegments); - return segmentPath.Replace(".[", "["); + + return new MemberPath(declaringType, segmentPath.Replace(".[", "[")); } internal static string GetMethodName(Expression action) diff --git a/Src/FluentAssertions/Common/MemberPath.cs b/Src/FluentAssertions/Common/MemberPath.cs new file mode 100644 index 0000000000..68753533bd --- /dev/null +++ b/Src/FluentAssertions/Common/MemberPath.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace FluentAssertions.Common +{ + /// + /// Encapsulates a dotted candidate to a (nested) member of a type as well as the + /// declaring type of the deepest member. + /// + internal class MemberPath + { + private readonly Type declaringType; + private readonly string dottedPath; + private readonly List segments = new List(); + + public MemberPath(Type declaringType, string dottedPath) + { + this.declaringType = declaringType; + this.dottedPath = dottedPath; + segments.AddRange(Segmentize(dottedPath)); + } + + public bool IsParentOrChildOf(string candidate) + { + return IsParent(candidate) || IsChild(candidate); + } + + public bool IsSameAs(string candidate, Type memberDeclaringType) + { + string[] candidateSegments = Segmentize(candidate); + + return memberDeclaringType == declaringType && candidateSegments.SequenceEqual(segments); + } + + private bool IsChild(string candidate) + { + string[] candidateSegments = Segmentize(candidate); + + return candidateSegments.Length > segments.Count && + candidateSegments.Take(segments.Count).SequenceEqual(segments); + } + + private bool IsParent(string candidate) + { + string[] candidateSegments = Segmentize(candidate); + + return candidateSegments.Length < segments.Count + && candidateSegments.SequenceEqual(segments.Take(candidateSegments.Length)); + } + + private static string[] Segmentize(string dottedPath) + { + return dottedPath.Split(new[] { '.', '[', ']' }, StringSplitOptions.RemoveEmptyEntries); + } + + public override string ToString() + { + return dottedPath; + } + } +} diff --git a/Src/FluentAssertions/Common/StringExtensions.cs b/Src/FluentAssertions/Common/StringExtensions.cs index 41df7e9ffb..6f72357adc 100644 --- a/Src/FluentAssertions/Common/StringExtensions.cs +++ b/Src/FluentAssertions/Common/StringExtensions.cs @@ -5,15 +5,6 @@ namespace FluentAssertions.Common { internal static class StringExtensions { - /// - /// Finds the first index at which the does not match the - /// string anymore, including the exact casing. - /// - public static int IndexOfFirstMismatch(this string value, string expected) - { - return IndexOfFirstMismatch(value, expected, StringComparison.CurrentCulture); - } - /// /// Finds the first index at which the does not match the /// string anymore, accounting for the specified . diff --git a/Src/FluentAssertions/Equivalency/EquivalencyAssertionOptions.cs b/Src/FluentAssertions/Equivalency/EquivalencyAssertionOptions.cs index 98a3d62d80..ec3114a9c4 100644 --- a/Src/FluentAssertions/Equivalency/EquivalencyAssertionOptions.cs +++ b/Src/FluentAssertions/Equivalency/EquivalencyAssertionOptions.cs @@ -68,7 +68,7 @@ public EquivalencyAssertionOptions Including(Expression WithStrictOrderingFor( Expression> expression) { - string expressionMemberPath = expression.GetMemberPath(); + string expressionMemberPath = expression.GetMemberPath().ToString(); orderingRules.Add(new PathBasedOrderingRule(expressionMemberPath)); return this; } diff --git a/Src/FluentAssertions/Equivalency/Selection/ExcludeMemberByPathSelectionRule.cs b/Src/FluentAssertions/Equivalency/Selection/ExcludeMemberByPathSelectionRule.cs index 634396e4fa..12d0645d6d 100644 --- a/Src/FluentAssertions/Equivalency/Selection/ExcludeMemberByPathSelectionRule.cs +++ b/Src/FluentAssertions/Equivalency/Selection/ExcludeMemberByPathSelectionRule.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using FluentAssertions.Common; @@ -9,23 +10,25 @@ namespace FluentAssertions.Equivalency.Selection /// internal class ExcludeMemberByPathSelectionRule : SelectMemberByPathSelectionRule { - private readonly string pathToExclude; + private readonly MemberPath memberToExclude; - public ExcludeMemberByPathSelectionRule(string pathToExclude) - : base(pathToExclude) + public ExcludeMemberByPathSelectionRule(MemberPath pathToExclude) + : base(pathToExclude.ToString()) { - this.pathToExclude = pathToExclude; + this.memberToExclude = pathToExclude; } protected override IEnumerable OnSelectMembers(IEnumerable selectedMembers, string currentPath, IMemberInfo context) { - return selectedMembers.Where(memberInfo => currentPath.Combine(memberInfo.Name) != pathToExclude).ToArray(); + return selectedMembers + .Where(memberInfo => !memberToExclude.IsSameAs(currentPath.Combine(memberInfo.Name), memberInfo.DeclaringType)) + .ToArray(); } public override string ToString() { - return "Exclude member root." + pathToExclude; + return "Exclude member root." + memberToExclude; } } } diff --git a/Src/FluentAssertions/Equivalency/Selection/IncludeMemberByPathSelectionRule.cs b/Src/FluentAssertions/Equivalency/Selection/IncludeMemberByPathSelectionRule.cs index 3dcc2e451d..f428475108 100644 --- a/Src/FluentAssertions/Equivalency/Selection/IncludeMemberByPathSelectionRule.cs +++ b/Src/FluentAssertions/Equivalency/Selection/IncludeMemberByPathSelectionRule.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using FluentAssertions.Common; @@ -9,12 +10,12 @@ namespace FluentAssertions.Equivalency.Selection /// internal class IncludeMemberByPathSelectionRule : SelectMemberByPathSelectionRule { - private readonly MemberPath pathToInclude; + private readonly MemberPath memberToInclude; - public IncludeMemberByPathSelectionRule(string pathToInclude) - : base(pathToInclude) + public IncludeMemberByPathSelectionRule(MemberPath pathToInclude) + : base(pathToInclude.ToString()) { - this.pathToInclude = new MemberPath(pathToInclude); + memberToInclude = pathToInclude; } public override bool IncludesMembers => true; @@ -24,7 +25,9 @@ public IncludeMemberByPathSelectionRule(string pathToInclude) { var matchingMembers = from member in context.RuntimeType.GetNonPrivateMembers() - where pathToInclude.IsParentOrChildOf(currentPath.Combine(member.Name)) + let memberPath = currentPath.Combine(member.Name) + where memberToInclude.IsSameAs(memberPath, member.DeclaringType) || + memberToInclude.IsParentOrChildOf(memberPath) select member; return selectedMembers.Concat(matchingMembers).ToArray(); @@ -32,7 +35,7 @@ where pathToInclude.IsParentOrChildOf(currentPath.Combine(member.Name)) public override string ToString() { - return "Include member root." + pathToInclude; + return "Include member root." + memberToInclude; } } } diff --git a/Src/FluentAssertions/Equivalency/Selection/MemberPath.cs b/Src/FluentAssertions/Equivalency/Selection/MemberPath.cs deleted file mode 100644 index 05975e8e46..0000000000 --- a/Src/FluentAssertions/Equivalency/Selection/MemberPath.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace FluentAssertions.Equivalency.Selection -{ - /// - /// Encapsulates a dotted candidate to a (nested) member of a type. - /// - internal class MemberPath - { - private readonly List segments = new List(); - - public MemberPath(string dottedPath) - { - segments.AddRange(Segmentize(dottedPath)); - } - - public bool IsParentOrChildOf(string candidate) - { - return IsParent(candidate) || IsChild(candidate); - } - - private bool IsChild(string candidate) - { - string[] candidateSegments = Segmentize(candidate); - - return candidateSegments.Take(segments.Count).SequenceEqual(segments); - } - - private bool IsParent(string candidate) - { - string[] candidateSegments = Segmentize(candidate); - - return candidateSegments.SequenceEqual(segments.Take(candidateSegments.Length)); - } - - private static string[] Segmentize(string dottedPath) - { - return dottedPath.Split(new[] { '.', '[', ']' }, StringSplitOptions.RemoveEmptyEntries); - } - } -} diff --git a/Tests/Shared.Specs/BasicEquivalencySpecs.cs b/Tests/Shared.Specs/BasicEquivalencySpecs.cs index 8b9320a83e..23089812cb 100644 --- a/Tests/Shared.Specs/BasicEquivalencySpecs.cs +++ b/Tests/Shared.Specs/BasicEquivalencySpecs.cs @@ -1191,6 +1191,51 @@ public void When_a_property_is_hidden_in_a_derived_class_it_should_ignore_it() action.Should().NotThrow(); } + [Fact] + public void When_including_a_property_that_is_hidden_in_a_derived_class_it_should_select_the_correct_one() + { + //----------------------------------------------------------------------------------------------------------- + // Arrange + //----------------------------------------------------------------------------------------------------------- + var b1 = new ClassThatHidesBaseClassProperty(); + var b2 = new ClassThatHidesBaseClassProperty(); + + //----------------------------------------------------------------------------------------------------------- + // Act / Assert + //----------------------------------------------------------------------------------------------------------- + b1.Should().BeEquivalentTo(b2, config => config.Including(b => b.Property)); + } + + [Fact] + public void When_excluding_a_property_that_is_hidden_in_a_derived_class_it_should_select_the_correct_one() + { + //----------------------------------------------------------------------------------------------------------- + // Arrange + //----------------------------------------------------------------------------------------------------------- + var b1 = new ClassThatHidesBaseClassProperty(); + var b2 = new ClassThatHidesBaseClassProperty(); + + //----------------------------------------------------------------------------------------------------------- + // Act + //----------------------------------------------------------------------------------------------------------- + Action act = () => b1.Should().BeEquivalentTo(b2, config => config.Excluding(b => b.Property)); + + //----------------------------------------------------------------------------------------------------------- + // Assert + //----------------------------------------------------------------------------------------------------------- + act.Should().Throw().WithMessage("Expected member Property*-*-*-*-*but*"); + } + + class ClassWithGuidProperty + { + public string Property { get; set; } = Guid.NewGuid().ToString(); + } + + class ClassThatHidesBaseClassProperty: ClassWithGuidProperty + { + public new string[] Property { get; set; } + } + [Fact] public void When_a_property_is_an_indexer_it_should_be_ignored() {