< Summary - pva.SuperV

Information
Class: pva.Helpers.Extensions.EquivalencyOptions<T>
Assembly: pva.Helpers
File(s): /home/runner/work/pva.SuperV/pva.SuperV/pva.Helpers/Extensions/ShouldlyExtensions.cs
Tag: dotnet-ubuntu_22190969454
Line coverage
20%
Covered lines: 8
Uncovered lines: 32
Coverable lines: 40
Total lines: 216
Line coverage: 20%
Branch coverage
0%
Covered branches: 0
Total branches: 12
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_ExcludedProperties()100%11100%
get_ExcludedTypes()100%11100%
get_DateTimeTolerance()100%11100%
Exclude(...)100%210%
ExcludeType(...)100%11100%
UsingDateTimeTolerance(...)100%210%
GetPropertyPath(...)100%210%
ExtractPropertyPath(...)0%156120%

File(s)

/home/runner/work/pva.SuperV/pva.SuperV/pva.Helpers/Extensions/ShouldlyExtensions.cs

#LineLine coverage
 1using Shouldly;
 2using System.Collections;
 3using System.Linq.Expressions;
 4
 5namespace pva.Helpers.Extensions;
 6
 7public class EquivalencyOptions<T>
 8{
 3859    public HashSet<string> ExcludedProperties { get; } = [];
 33510    public HashSet<Type> ExcludedTypes { get; } = [];
 111    public TimeSpan? DateTimeTolerance { get; private set; }
 12
 13    public EquivalencyOptions<T> Exclude(Expression<Func<T, object>> propertyExpression)
 014    {
 015        ArgumentNullException.ThrowIfNull(propertyExpression);
 16
 017        ExcludedProperties.Add(GetPropertyPath(propertyExpression));
 018        return this;
 019    }
 20
 21    public EquivalencyOptions<T> ExcludeType(Type type)
 1522    {
 1523        ArgumentNullException.ThrowIfNull(type);
 24
 1525        ExcludedTypes.Add(type);
 1526        return this;
 1527    }
 28
 29    public EquivalencyOptions<T> UsingDateTimeTolerance(TimeSpan tolerance)
 030    {
 031        DateTimeTolerance = tolerance;
 032        return this;
 033    }
 34
 35    private static string GetPropertyPath(Expression expression)
 036    {
 037        var propertyNames = new List<string>();
 038        ExtractPropertyPath(expression, propertyNames);
 039        propertyNames.Reverse();
 040        return string.Join(".", propertyNames);
 041    }
 42
 43    private static void ExtractPropertyPath(Expression expression, List<string> propertyNames)
 044    {
 045        switch (expression)
 46        {
 47            case MemberExpression memberExpression:
 048                propertyNames.Add(memberExpression.Member.Name);
 049                ExtractPropertyPath(memberExpression.Expression!, propertyNames);
 050                break;
 51            case MethodCallExpression methodCallExpression:
 052                foreach (var argument in methodCallExpression.Arguments.Reverse())
 053                {
 054                    ExtractPropertyPath(argument, propertyNames);
 055                }
 056                break;
 57            case UnaryExpression unaryExpression:
 058                ExtractPropertyPath(unaryExpression.Operand, propertyNames);
 059                break;
 60            case LambdaExpression lambdaExpression:
 061                ExtractPropertyPath(lambdaExpression.Body, propertyNames);
 062                break;
 63            case ParameterExpression:
 064                break;
 65            default:
 066                throw new ArgumentException("Invalid property expression");
 67        }
 068    }
 69}
 70
 71
 72[ShouldlyMethods]
 73public static class ShouldlyExtensions
 74{
 75    public static void ShouldBeEquivalentTo<T>(this T? actual, T? expected, Action<EquivalencyOptions<T>> options) where
 76    {
 77        if (typeof(IEnumerable).IsAssignableFrom(typeof(T)) && typeof(T) != typeof(string))
 78        {
 79            throw new InvalidOperationException("This overload is not intended for collection types.");
 80        }
 81
 82        var equivalencyOptions = ValidateAndSetupOptions(actual, expected, options);
 83
 84        CompareObjects(actual, expected, equivalencyOptions, new HashSet<(object, object)>());
 85    }
 86
 87    public static void ShouldBeEquivalentTo<T>(this IEnumerable<T?> actual, IEnumerable<T?> expected, Action<Equivalency
 88    {
 89        var equivalencyOptions = ValidateAndSetupOptions(actual, expected, options);
 90        var actualList = actual.ToList();
 91        var expectedList = expected.ToList();
 92
 93        actualList.Count.ShouldBe(expectedList.Count, "Collection counts do not match.");
 94
 95        for (int i = 0; i < actualList.Count; i++)
 96        {
 97            CompareObjects(actualList[i], expectedList[i], equivalencyOptions, new HashSet<(object, object)>());
 98        }
 99    }
 100
 101    private static EquivalencyOptions<T> ValidateAndSetupOptions<T>(object? actual, object? expected, Action<Equivalency
 102    {
 103        if (actual == null && expected != null || actual != null && expected == null)
 104        {
 105            throw new ShouldAssertException(@$"Comparing object equivalence:
 106actual: {actual?.GetType().ToString() ?? "null"}
 107expected: {expected?.GetType().ToString() ?? "null"}");
 108        }
 109
 110        var equivalencyOptions = new EquivalencyOptions<T>();
 111        options(equivalencyOptions);
 112
 113        return equivalencyOptions;
 114    }
 115
 116    private static void CompareObjects<T>(object? actual, object? expected, EquivalencyOptions<T> options, HashSet<(obje
 117    {
 118        if (actual == null && expected == null)
 119        {
 120            return;
 121        }
 122
 123        if (actual == null || expected == null)
 124        {
 125            throw new ShouldAssertException(@$"Comparing object equivalence, at path '{parentPath}':
 126actual: {actual?.GetType().ToString() ?? "null"}
 127expected: {expected?.GetType().ToString() ?? "null"}");
 128        }
 129
 130        if (visitedObjects.Contains((actual, expected)))
 131        {
 132            return;
 133        }
 134        visitedObjects.Add((actual, expected));
 135
 136        foreach (var property in actual.GetType().GetProperties())
 137        {
 138            var propertyPath = string.IsNullOrEmpty(parentPath) ? property.Name : $"{parentPath}.{property.Name}";
 139            if (property.GetIndexParameters().Length > 0)
 140            {
 141                continue; // Skip indexers
 142            }
 143            object? actualPropertyValue = property.GetValue(actual, null);
 144            if (options.ExcludedProperties.Contains(propertyPath)
 145                || (actualPropertyValue != null && IsOfType(options, actualPropertyValue)))
 146            {
 147                continue;
 148            }
 149
 150            ComparePropertyValues(property.GetValue(actual, null), property.GetValue(expected, null), property, options,
 151        }
 152    }
 153
 154    private static bool IsOfType<T>(EquivalencyOptions<T> options, object actualPropertyValue)
 155    {
 156        return options.ExcludedTypes.Any(type => actualPropertyValue.GetType().IsSubclassOf(type));
 157    }
 158
 159    private static void ComparePropertyValues<T>(object? actualValue, object? expectedValue, System.Reflection.PropertyI
 160    {
 161        var hasNull = actualValue == null || expectedValue == null;
 162        if (!hasNull && property.PropertyType == typeof(DateTime) && options.DateTimeTolerance.HasValue)
 163        {
 164            ((DateTime)actualValue!).ShouldBe((DateTime)expectedValue!, options.DateTimeTolerance.Value, $"Property {pro
 165        }
 166        else if (!hasNull && property.PropertyType == typeof(DateTimeOffset) && options.DateTimeTolerance.HasValue)
 167        {
 168            ((DateTimeOffset)actualValue!).ShouldBe((DateTimeOffset)expectedValue!, options.DateTimeTolerance.Value, $"P
 169        }
 170        else if (!hasNull && IsCollectionType(property.PropertyType))
 171        {
 172            CompareCollections(actualValue!, expectedValue!, options, visitedObjects, propertyPath);
 173        }
 174        else if (!hasNull && actualValue.GetType().IsTypeDefinition)
 175        {
 176            actualValue.ShouldBeEquivalentTo(expectedValue, $"Property {propertyPath} does not match.");
 177        }
 178        else if (!hasNull && IsComplexType(actualValue!))
 179        {
 180            CompareObjects(actualValue, expectedValue, options, visitedObjects, propertyPath);
 181        }
 182        else
 183        {
 184            actualValue.ShouldBe(expectedValue, $"Property {propertyPath} does not match.");
 185        }
 186    }
 187
 188    private static void CompareCollections<T>(object actual, object expected, EquivalencyOptions<T> options, HashSet<(ob
 189    {
 190        var actualList = ((IEnumerable<object>)actual).ToList();
 191        var expectedList = ((IEnumerable<object>)expected).ToList();
 192
 193        actualList.Count.ShouldBe(expectedList.Count, "Collection counts do not match.");
 194
 195        for (int i = 0; i < actualList.Count; i++)
 196        {
 197            CompareObjects(actualList[i], expectedList[i], options, visitedObjects, parentPath);
 198        }
 199    }
 200
 201    private static bool IsCollectionType(Type type)
 202    {
 203        return typeof(IEnumerable).IsAssignableFrom(type) && type != typeof(string);
 204    }
 205
 206    private static bool IsComplexType(object obj)
 207    {
 208        var type = obj.GetType();
 209        return !type.IsPrimitive
 210            && !type.IsEnum
 211            && type != typeof(string)
 212            && type != typeof(decimal)
 213            && type != typeof(DateTime)
 214            && type != typeof(DateTimeOffset);
 215    }
 216}