Skip to content

Commit

Permalink
Adds WithMapping to map properties and fields between subject and exp…
Browse files Browse the repository at this point in the history
…ectation (#1742)
  • Loading branch information
dennisdoomen committed Jan 23, 2022
1 parent 35d018a commit 216646a
Show file tree
Hide file tree
Showing 18 changed files with 938 additions and 24 deletions.
39 changes: 37 additions & 2 deletions Src/FluentAssertions/Common/MemberPath.cs
@@ -1,6 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using FluentAssertions.Equivalency;

namespace FluentAssertions.Common
Expand All @@ -23,9 +23,18 @@ public MemberPath(IMember member, string parentPath)
}

public MemberPath(Type reflectedType, Type declaringType, string dottedPath)
: this(dottedPath)
{
this.reflectedType = reflectedType;
this.declaringType = declaringType;
}

public MemberPath(string dottedPath)
{
Guard.ThrowIfArgumentIsNullOrEmpty(
dottedPath, nameof(dottedPath),
"A member path cannot be null or empty");

this.dottedPath = dottedPath;
}

Expand All @@ -40,7 +49,7 @@ public bool IsParentOrChildOf(MemberPath candidate)

public bool IsSameAs(MemberPath candidate)
{
if ((declaringType == candidate.declaringType) || declaringType.IsAssignableFrom(candidate.reflectedType))
if ((declaringType == candidate.declaringType) || declaringType?.IsAssignableFrom(candidate.reflectedType) == true)
{
string[] candidateSegments = candidate.Segments;

Expand All @@ -66,8 +75,34 @@ private bool IsChildOf(MemberPath candidate)
&& candidateSegments.SequenceEqual(Segments.Take(candidateSegments.Length));
}

/// <summary>
/// Determines whether the current path is the same as <paramref name="path"/> when ignoring any specific indexes.
/// </summary>
public bool IsEquivalentTo(string path)
{
return path.WithoutSpecificCollectionIndices() == dottedPath.WithoutSpecificCollectionIndices();
}

public bool HasSameParentAs(MemberPath path)
{
return Segments.Length == path.Segments.Length
&& GetParentSegments().SequenceEqual(path.GetParentSegments());
}

private IEnumerable<string> GetParentSegments() => Segments.Take(Segments.Length - 1);

/// <summary>
/// Gets a value indicating whether the current path contains an indexer like `[1]` instead of `[]`.
/// </summary>
public bool GetContainsSpecificCollectionIndex() => dottedPath.ContainsSpecificCollectionIndex();

private string[] Segments => segments ??= dottedPath.Split(new[] { '.', '[', ']' }, StringSplitOptions.RemoveEmptyEntries);

/// <summary>
/// Returns the name of the member the current path points to without its parent path.
/// </summary>
public string MemberName => Segments.Last();

public override string ToString()
{
return dottedPath;
Expand Down
17 changes: 17 additions & 0 deletions Src/FluentAssertions/Common/StringExtensions.cs
@@ -1,5 +1,6 @@
using System;
using System.Linq;
using System.Text.RegularExpressions;
using FluentAssertions.Formatting;

namespace FluentAssertions.Common
Expand Down Expand Up @@ -41,6 +42,22 @@ public static string IndexedSegmentAt(this string value, int index)
return $"{formattedString} (index {index})".EscapePlaceholders();
}

/// <summary>
/// Replaces all numeric indices from a path like "property[0].nested" and returns "property[].nested"
/// </summary>
public static string WithoutSpecificCollectionIndices(this string indexedPath)
{
return Regex.Replace(indexedPath, @"\[\d+\]", "[]");
}

/// <summary>
/// Determines whether a string contains a specific index like `[0]` instead of just `[]`.
/// </summary>
public static bool ContainsSpecificCollectionIndex(this string indexedPath)
{
return Regex.IsMatch(indexedPath, @"\[\d+\]");
}

/// <summary>
/// Replaces all characters that might conflict with formatting placeholders with their escaped counterparts.
/// </summary>
Expand Down
91 changes: 91 additions & 0 deletions Src/FluentAssertions/Equivalency/EquivalencyAssertionOptions.cs
Expand Up @@ -2,9 +2,11 @@

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using FluentAssertions.Common;
using FluentAssertions.Equivalency.Execution;
using FluentAssertions.Equivalency.Matching;
using FluentAssertions.Equivalency.Ordering;
using FluentAssertions.Equivalency.Selection;

Expand Down Expand Up @@ -70,6 +72,95 @@ public EquivalencyAssertionOptions<IEnumerable<TExpectation>> AsCollection()
return new EquivalencyAssertionOptions<IEnumerable<TExpectation>>(
new CollectionMemberAssertionOptionsDecorator(this));
}

/// <summary>
/// Maps a (nested) property or field of type <typeparamref name="TExpectation"/> to
/// a (nested) property or field of <typeparamref name="TSubject"/> using lambda expressions.
/// </summary>
/// <param name="expectationMemberPath">A field or property expression indicating the (nested) member to map from.</param>
/// <param name="subjectMemberPath">A field or property expression indicating the (nested) member to map to.</param>
/// <remarks>
/// The members of the subject and the expectation must have the same parent. Also, indexes in collections are ignored.
/// If the types of the members are different, the usual logic applies depending or not if conversion options were specified.
/// Fields can be mapped to properties and vice-versa.
/// </remarks>
public EquivalencyAssertionOptions<TExpectation> WithMapping<TSubject>(
Expression<Func<TExpectation, object>> expectationMemberPath,
Expression<Func<TSubject, object>> subjectMemberPath)
{
return WithMapping(
expectationMemberPath.GetMemberPath().ToString().WithoutSpecificCollectionIndices(),
subjectMemberPath.GetMemberPath().ToString().WithoutSpecificCollectionIndices());
}

/// <summary>
/// Maps a (nested) property or field of the expectation to a (nested) property or field of the subject using a path string.
/// </summary>
/// <param name="expectationMemberPath">
/// A field or property path indicating the (nested) member to map from in the format <c>Parent.Child.Collection[].Member</c>.
/// </param>
/// <param name="subjectMemberPath">
/// A field or property path indicating the (nested) member to map to in the format <c>Parent.Child.Collection[].Member</c>.
/// </param>
/// <remarks>
/// The members of the subject and the expectation must have the same parent. Also, indexes in collections are not allowed
/// and must be written as "[]". If the types of the members are different, the usual logic applies depending or not
/// if conversion options were specified.
/// Fields can be mapped to properties and vice-versa.
/// </remarks>
public EquivalencyAssertionOptions<TExpectation> WithMapping(
string expectationMemberPath,
string subjectMemberPath)
{
AddMatchingRule(new MappedPathMatchingRule(expectationMemberPath, subjectMemberPath));

return this;
}

/// <summary>
/// Maps a direct property or field of type <typeparamref name="TNestedExpectation"/> to
/// a direct property or field of <typeparamref name="TNestedSubject"/> using lambda expressions.
/// </summary>
/// <param name="expectationMember">A field or property expression indicating the member to map from.</param>
/// <param name="subjectMember">A field or property expression indicating the member to map to.</param>
/// <remarks>
/// Only direct members of <typeparamref name="TNestedExpectation"/> and <typeparamref name="TNestedSubject"/> can be
/// mapped to each other. Those types can appear anywhere in the object graphs that are being compared.
/// If the types of the members are different, the usual logic applies depending or not if conversion options were specified.
/// Fields can be mapped to properties and vice-versa.
/// </remarks>
public EquivalencyAssertionOptions<TExpectation> WithMapping<TNestedExpectation, TNestedSubject>(
Expression<Func<TNestedExpectation, object>> expectationMember,
Expression<Func<TNestedSubject, object>> subjectMember)
{
return WithMapping<TNestedExpectation, TNestedSubject>(
expectationMember.GetMemberPath().ToString(),
subjectMember.GetMemberPath().ToString());
}

/// <summary>
/// Maps a direct property or field of type <typeparamref name="TNestedExpectation"/> to
/// a direct property or field of <typeparamref name="TNestedSubject"/> using member names.
/// </summary>
/// <param name="expectationMemberName">A field or property name indicating the member to map from.</param>
/// <param name="subjectMemberName">A field or property name indicating the member to map to.</param>
/// <remarks>
/// Only direct members of <typeparamref name="TNestedExpectation"/> and <typeparamref name="TNestedSubject"/> can be
/// mapped to each other, so no <c>.</c> or <c>[]</c> are allowed.
/// Those types can appear anywhere in the object graphs that are being compared.
/// If the types of the members are different, the usual logic applies depending or not if conversion options were specified.
/// Fields can be mapped to properties and vice-versa.
/// </remarks>
public EquivalencyAssertionOptions<TExpectation> WithMapping<TNestedExpectation, TNestedSubject>(
string expectationMemberName,
string subjectMemberName)
{
AddMatchingRule(new MappedMemberMatchingRule<TNestedExpectation, TNestedSubject>(
expectationMemberName,
subjectMemberName));

return this;
}
}

/// <summary>
Expand Down
14 changes: 13 additions & 1 deletion Src/FluentAssertions/Equivalency/INode.cs
Expand Up @@ -16,7 +16,10 @@ public interface INode
/// <summary>
/// Gets the name of this node.
/// </summary>
string Name { get; }
/// <example>
/// "Property2"
/// </example>
string Name { get; set; }

/// <summary>
/// Gets the type of this node.
Expand All @@ -26,11 +29,17 @@ public interface INode
/// <summary>
/// Gets the path from the root object UNTIL the current node, separated by dots or index/key brackets.
/// </summary>
/// <example>
/// "Parent[0].Property2"
/// </example>
string Path { get; }

/// <summary>
/// Gets the full path from the root object up to and including the name of the node.
/// </summary>
/// <example>
/// "Parent[0]"
/// </example>
string PathAndName { get; }

/// <summary>
Expand All @@ -41,6 +50,9 @@ public interface INode
/// <summary>
/// Gets the path including the description of the subject.
/// </summary>
/// <example>
/// "property subject.Parent[0].Property2"
/// </example>
string Description { get; }

/// <summary>
Expand Down
@@ -0,0 +1,52 @@
using System;
using System.Text.RegularExpressions;
using FluentAssertions.Common;

namespace FluentAssertions.Equivalency.Matching
{
/// <summary>
/// Allows mapping a member (property or field) of the expectation to a differently named member
/// of the subject-under-test using a member name and the target type.
/// </summary>
internal class MappedMemberMatchingRule<TExpectation, TSubject> : IMemberMatchingRule
{
private readonly string expectationMemberName;
private readonly string subjectMemberName;

public MappedMemberMatchingRule(string expectationMemberName, string subjectMemberName)
{
if (Regex.IsMatch(expectationMemberName, @"[\.\[\]]"))
{
throw new ArgumentException("The expectation's member name cannot be a nested path");
}

if (Regex.IsMatch(subjectMemberName, $@"[\.\[\]]"))
{
throw new ArgumentException("The subject's member name cannot be a nested path");
}

this.expectationMemberName = expectationMemberName;
this.subjectMemberName = subjectMemberName;
}

public IMember Match(IMember expectedMember, object subject, INode parent, IEquivalencyAssertionOptions options)
{
if (parent.Type.IsSameOrInherits(typeof(TExpectation)) && subject is TSubject)
{
if (expectedMember.Name == expectationMemberName)
{
var member = MemberFactory.Find(subject, subjectMemberName, expectedMember.Type, parent);
if (member is null)
{
throw new ArgumentException(
$"Subject of type {typeof(TSubject)} does not have member {subjectMemberName}");
}

return member;
}
}

return null;
}
}
}
@@ -0,0 +1,48 @@
using System;
using FluentAssertions.Common;

namespace FluentAssertions.Equivalency.Matching
{
/// <summary>
/// Allows mapping a member (property or field) of the expectation to a differently named member
/// of the subject-under-test using a nested member path in the form of "Parent.NestedCollection[].Member"
/// </summary>
internal class MappedPathMatchingRule : IMemberMatchingRule
{
private readonly MemberPath expectationPath;
private readonly MemberPath subjectPath;

public MappedPathMatchingRule(string expectationMemberPath, string subjectMemberPath)
{
expectationPath = new MemberPath(expectationMemberPath);
subjectPath = new MemberPath(subjectMemberPath);

if (expectationPath.GetContainsSpecificCollectionIndex() || subjectPath.GetContainsSpecificCollectionIndex())
{
throw new ArgumentException("Mapping properties containing a collection index must use the [] format without specific index.");
}

if (!expectationPath.HasSameParentAs(subjectPath))
{
throw new ArgumentException("The member paths must have the same parent.");
}
}

public IMember Match(IMember expectedMember, object subject, INode parent, IEquivalencyAssertionOptions options)
{
if (expectationPath.IsEquivalentTo(expectedMember.PathAndName))
{
var member = MemberFactory.Find(subject, subjectPath.MemberName, expectedMember.Type, parent);
if (member is null)
{
throw new ArgumentException(
$"Subject of type {subject?.GetType().Name} does not have member {subjectPath.MemberName}");
}

return member;
}

return null;
}
}
}
13 changes: 13 additions & 0 deletions Src/FluentAssertions/Equivalency/MemberFactory.cs
@@ -1,5 +1,6 @@
using System;
using System.Reflection;
using FluentAssertions.Common;

namespace FluentAssertions.Equivalency
{
Expand All @@ -19,5 +20,17 @@ public static IMember Create(MemberInfo memberInfo, INode parent)

throw new NotSupportedException($"Don't know how to deal with a {memberInfo.MemberType}");
}

internal static IMember Find(object target, string memberName, Type preferredMemberType, INode parent)
{
PropertyInfo property = target.GetType().FindProperty(memberName, preferredMemberType);
if ((property is not null) && !property.IsIndexer())
{
return new Property(property, parent);
}

FieldInfo field = target.GetType().FindField(memberName, preferredMemberType);
return (field is not null) ? new Field(field, parent) : null;
}
}
}
2 changes: 1 addition & 1 deletion Src/FluentAssertions/Equivalency/Node.cs
Expand Up @@ -21,7 +21,7 @@ public class Node : INode

public string PathAndName => Path.Combine(Name);

public string Name { get; protected set; }
public string Name { get; set; }

public virtual string Description => $"{GetSubjectId().Combine(PathAndName)}";

Expand Down
Expand Up @@ -822,7 +822,7 @@ protected TSelf AddSelectionRule(IMemberSelectionRule selectionRule)
return (TSelf)this;
}

private TSelf AddMatchingRule(IMemberMatchingRule matchingRule)
protected TSelf AddMatchingRule(IMemberMatchingRule matchingRule)
{
matchingRules.Insert(0, matchingRule);
return (TSelf)this;
Expand Down

0 comments on commit 216646a

Please sign in to comment.