diff --git a/Src/FluentAssertions/Equivalency/EquivalencyValidator.cs b/Src/FluentAssertions/Equivalency/EquivalencyValidator.cs index 6a796efdec..85681d8ecf 100644 --- a/Src/FluentAssertions/Equivalency/EquivalencyValidator.cs +++ b/Src/FluentAssertions/Equivalency/EquivalencyValidator.cs @@ -1,4 +1,5 @@ using System; +using FluentAssertions.Common; using FluentAssertions.Equivalency.Tracing; using FluentAssertions.Execution; @@ -16,7 +17,7 @@ public void AssertEquality(Comparands comparands, EquivalencyValidationContext c context.ResetTracing(); using var scope = new AssertionScope(); - + RecursivelyAssertEquivalencyOf(comparands, context); if (context.TraceWriter is not null) @@ -38,6 +39,50 @@ public void AssertEquivalencyOf(Comparands comparands, IEquivalencyValidationCon if (ShouldContinueThisDeep(context.CurrentNode, context.Options, assertionChain)) { + // Fail when a path-based ExcludeMemberByPathSelectionRule targets this node but its type is compared using value semantics. + foreach (var rule in context.Options.SelectionRules) + { + if (rule is FluentAssertions.Equivalency.Selection.ExcludeMemberByPathSelectionRule excludeRule) + { + try + { + try + { + var nodePath = new MemberPath(context.CurrentNode.Subject.PathAndName); + + if (nodePath.IsParentOrChildOf(excludeRule.CurrentPath) || nodePath.IsSameAs(excludeRule.CurrentPath)) + { + Type typeToCheck = context.CurrentNode.Type; + + // Ignore primitive/value types and strings — excluding these is harmless and expected. + if (typeToCheck.IsValueType || typeToCheck == typeof(string)) + { + continue; + } + + EqualityStrategy strategy = context.Options.GetEqualityStrategy(typeToCheck); + + if ((strategy is EqualityStrategy.Equals or EqualityStrategy.ForceEquals) + && (typeToCheck.OverridesEquals() || strategy == EqualityStrategy.ForceEquals)) + { + assertionChain.FailWith( + "Cannot apply member-level inclusion/exclusion to path {0} because the type {1} is compared using value semantics (Equals). Configure it to be compared by members instead (for example, using ComparingByMembers<{2}>()) before applying member-level rules.", + excludeRule.CurrentPath, typeToCheck, typeToCheck); + } + } + } + catch + { + // Swallow any unexpected errors during inspection to avoid changing behavior in unknown cases. + } + } + catch + { + // Swallow any unexpected errors during inspection to avoid changing behavior in unknown cases. + } + } + } + if (!context.IsCyclicReference(comparands.Expectation)) { TryToProveNodesAreEquivalent(comparands, context); diff --git a/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.Excluding.cs b/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.Excluding.cs index 44b8686a82..07385ca190 100644 --- a/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.Excluding.cs +++ b/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.Excluding.cs @@ -1181,6 +1181,21 @@ public void Can_exclude_root_properties_by_name() .ExcludingMembersNamed("LastName", "Age")); } + [Fact] + public void Excluding_a_member_on_a_type_with_value_semantics_should_throw() + { + // Arrange + var subject = new ValueSemanticWithProperty { Value = 1 }; + var expectation = new ValueSemanticWithProperty { Value = 2 }; + + // Act + Action act = () => subject.Should().BeEquivalentTo(expectation, opts => opts + .Excluding(x => x.Value)); + + // Assert + act.Should().Throw().WithMessage("*Cannot apply member-level inclusion/exclusion*"); + } + [Fact] public void Can_exclude_properties_deeper_in_the_graph_by_name() { diff --git a/Tests/FluentAssertions.Equivalency.Specs/TestTypes.cs b/Tests/FluentAssertions.Equivalency.Specs/TestTypes.cs index 2b42827e78..49805be92c 100644 --- a/Tests/FluentAssertions.Equivalency.Specs/TestTypes.cs +++ b/Tests/FluentAssertions.Equivalency.Specs/TestTypes.cs @@ -388,6 +388,21 @@ public object ToType(Type conversionType, IFormatProvider provider) } } +public class ValueSemanticWithProperty +{ + public int Value { get; set; } + + public override bool Equals(object obj) + { + return obj is ValueSemanticWithProperty other && other.Value == Value; + } + + public override int GetHashCode() + { + return Value.GetHashCode(); + } +} + public abstract class Base { public abstract string AbstractProperty { get; }