| | | 1 | | using Shouldly; |
| | | 2 | | using System.Collections; |
| | | 3 | | using System.Linq.Expressions; |
| | | 4 | | |
| | | 5 | | namespace pva.Helpers.Extensions; |
| | | 6 | | |
| | | 7 | | public class EquivalencyOptions<T> |
| | | 8 | | { |
| | | 9 | | public HashSet<string> ExcludedProperties { get; } = []; |
| | | 10 | | public HashSet<Type> ExcludedTypes { get; } = []; |
| | | 11 | | public TimeSpan? DateTimeTolerance { get; private set; } |
| | | 12 | | |
| | | 13 | | public EquivalencyOptions<T> Exclude(Expression<Func<T, object>> propertyExpression) |
| | | 14 | | { |
| | | 15 | | ArgumentNullException.ThrowIfNull(propertyExpression); |
| | | 16 | | |
| | | 17 | | ExcludedProperties.Add(GetPropertyPath(propertyExpression)); |
| | | 18 | | return this; |
| | | 19 | | } |
| | | 20 | | |
| | | 21 | | public EquivalencyOptions<T> ExcludeType(Type type) |
| | | 22 | | { |
| | | 23 | | ArgumentNullException.ThrowIfNull(type); |
| | | 24 | | |
| | | 25 | | ExcludedTypes.Add(type); |
| | | 26 | | return this; |
| | | 27 | | } |
| | | 28 | | |
| | | 29 | | public EquivalencyOptions<T> UsingDateTimeTolerance(TimeSpan tolerance) |
| | | 30 | | { |
| | | 31 | | DateTimeTolerance = tolerance; |
| | | 32 | | return this; |
| | | 33 | | } |
| | | 34 | | |
| | | 35 | | private static string GetPropertyPath(Expression expression) |
| | | 36 | | { |
| | | 37 | | var propertyNames = new List<string>(); |
| | | 38 | | ExtractPropertyPath(expression, propertyNames); |
| | | 39 | | propertyNames.Reverse(); |
| | | 40 | | return string.Join(".", propertyNames); |
| | | 41 | | } |
| | | 42 | | |
| | | 43 | | private static void ExtractPropertyPath(Expression expression, List<string> propertyNames) |
| | | 44 | | { |
| | | 45 | | switch (expression) |
| | | 46 | | { |
| | | 47 | | case MemberExpression memberExpression: |
| | | 48 | | propertyNames.Add(memberExpression.Member.Name); |
| | | 49 | | ExtractPropertyPath(memberExpression.Expression!, propertyNames); |
| | | 50 | | break; |
| | | 51 | | case MethodCallExpression methodCallExpression: |
| | | 52 | | foreach (var argument in methodCallExpression.Arguments.Reverse()) |
| | | 53 | | { |
| | | 54 | | ExtractPropertyPath(argument, propertyNames); |
| | | 55 | | } |
| | | 56 | | break; |
| | | 57 | | case UnaryExpression unaryExpression: |
| | | 58 | | ExtractPropertyPath(unaryExpression.Operand, propertyNames); |
| | | 59 | | break; |
| | | 60 | | case LambdaExpression lambdaExpression: |
| | | 61 | | ExtractPropertyPath(lambdaExpression.Body, propertyNames); |
| | | 62 | | break; |
| | | 63 | | case ParameterExpression: |
| | | 64 | | break; |
| | | 65 | | default: |
| | | 66 | | throw new ArgumentException("Invalid property expression"); |
| | | 67 | | } |
| | | 68 | | } |
| | | 69 | | } |
| | | 70 | | |
| | | 71 | | |
| | | 72 | | [ShouldlyMethods] |
| | | 73 | | public static class ShouldlyExtensions |
| | | 74 | | { |
| | | 75 | | public static void ShouldBeEquivalentTo<T>(this T? actual, T? expected, Action<EquivalencyOptions<T>> options) where |
| | 15 | 76 | | { |
| | 15 | 77 | | if (typeof(IEnumerable).IsAssignableFrom(typeof(T)) && typeof(T) != typeof(string)) |
| | 0 | 78 | | { |
| | 0 | 79 | | throw new InvalidOperationException("This overload is not intended for collection types."); |
| | | 80 | | } |
| | | 81 | | |
| | 15 | 82 | | var equivalencyOptions = ValidateAndSetupOptions(actual, expected, options); |
| | | 83 | | |
| | 15 | 84 | | CompareObjects(actual, expected, equivalencyOptions, new HashSet<(object, object)>()); |
| | 15 | 85 | | } |
| | | 86 | | |
| | | 87 | | public static void ShouldBeEquivalentTo<T>(this IEnumerable<T?> actual, IEnumerable<T?> expected, Action<Equivalency |
| | 0 | 88 | | { |
| | 0 | 89 | | var equivalencyOptions = ValidateAndSetupOptions(actual, expected, options); |
| | 0 | 90 | | var actualList = actual.ToList(); |
| | 0 | 91 | | var expectedList = expected.ToList(); |
| | | 92 | | |
| | 0 | 93 | | actualList.Count.ShouldBe(expectedList.Count, "Collection counts do not match."); |
| | | 94 | | |
| | 0 | 95 | | for (int i = 0; i < actualList.Count; i++) |
| | 0 | 96 | | { |
| | 0 | 97 | | CompareObjects(actualList[i], expectedList[i], equivalencyOptions, new HashSet<(object, object)>()); |
| | 0 | 98 | | } |
| | 0 | 99 | | } |
| | | 100 | | |
| | | 101 | | private static EquivalencyOptions<T> ValidateAndSetupOptions<T>(object? actual, object? expected, Action<Equivalency |
| | 15 | 102 | | { |
| | 15 | 103 | | if (actual == null && expected != null || actual != null && expected == null) |
| | 0 | 104 | | { |
| | 0 | 105 | | throw new ShouldAssertException(@$"Comparing object equivalence: |
| | 0 | 106 | | actual: {actual?.GetType().ToString() ?? "null"} |
| | 0 | 107 | | expected: {expected?.GetType().ToString() ?? "null"}"); |
| | | 108 | | } |
| | | 109 | | |
| | 15 | 110 | | var equivalencyOptions = new EquivalencyOptions<T>(); |
| | 15 | 111 | | options(equivalencyOptions); |
| | | 112 | | |
| | 15 | 113 | | return equivalencyOptions; |
| | 15 | 114 | | } |
| | | 115 | | |
| | | 116 | | private static void CompareObjects<T>(object? actual, object? expected, EquivalencyOptions<T> options, HashSet<(obje |
| | 165 | 117 | | { |
| | 165 | 118 | | if (actual == null && expected == null) |
| | 0 | 119 | | { |
| | 0 | 120 | | return; |
| | | 121 | | } |
| | | 122 | | |
| | 165 | 123 | | if (actual == null || expected == null) |
| | 0 | 124 | | { |
| | 0 | 125 | | throw new ShouldAssertException(@$"Comparing object equivalence, at path '{parentPath}': |
| | 0 | 126 | | actual: {actual?.GetType().ToString() ?? "null"} |
| | 0 | 127 | | expected: {expected?.GetType().ToString() ?? "null"}"); |
| | | 128 | | } |
| | | 129 | | |
| | 165 | 130 | | if (visitedObjects.Contains((actual, expected))) |
| | 53 | 131 | | { |
| | 53 | 132 | | return; |
| | | 133 | | } |
| | 112 | 134 | | visitedObjects.Add((actual, expected)); |
| | | 135 | | |
| | 1216 | 136 | | foreach (var property in actual.GetType().GetProperties()) |
| | 440 | 137 | | { |
| | 440 | 138 | | var propertyPath = string.IsNullOrEmpty(parentPath) ? property.Name : $"{parentPath}.{property.Name}"; |
| | 440 | 139 | | if (property.GetIndexParameters().Length > 0) |
| | 70 | 140 | | { |
| | 70 | 141 | | continue; // Skip indexers |
| | | 142 | | } |
| | 370 | 143 | | object? actualPropertyValue = property.GetValue(actual, null); |
| | 370 | 144 | | if (options.ExcludedProperties.Contains(propertyPath) |
| | 370 | 145 | | || (actualPropertyValue != null && IsOfType(options, actualPropertyValue))) |
| | 2 | 146 | | { |
| | 2 | 147 | | continue; |
| | | 148 | | } |
| | | 149 | | |
| | 368 | 150 | | ComparePropertyValues(property.GetValue(actual, null), property.GetValue(expected, null), property, options, |
| | 368 | 151 | | } |
| | 165 | 152 | | } |
| | | 153 | | |
| | | 154 | | private static bool IsOfType<T>(EquivalencyOptions<T> options, object actualPropertyValue) |
| | 305 | 155 | | { |
| | 610 | 156 | | return options.ExcludedTypes.Any(type => actualPropertyValue.GetType().IsSubclassOf(type)); |
| | 305 | 157 | | } |
| | | 158 | | |
| | | 159 | | private static void ComparePropertyValues<T>(object? actualValue, object? expectedValue, System.Reflection.PropertyI |
| | 368 | 160 | | { |
| | 368 | 161 | | var hasNull = actualValue == null || expectedValue == null; |
| | 368 | 162 | | if (!hasNull && property.PropertyType == typeof(DateTime) && options.DateTimeTolerance.HasValue) |
| | 0 | 163 | | { |
| | 0 | 164 | | ((DateTime)actualValue!).ShouldBe((DateTime)expectedValue!, options.DateTimeTolerance.Value, $"Property {pro |
| | 0 | 165 | | } |
| | 368 | 166 | | else if (!hasNull && property.PropertyType == typeof(DateTimeOffset) && options.DateTimeTolerance.HasValue) |
| | 0 | 167 | | { |
| | 0 | 168 | | ((DateTimeOffset)actualValue!).ShouldBe((DateTimeOffset)expectedValue!, options.DateTimeTolerance.Value, $"P |
| | 0 | 169 | | } |
| | 368 | 170 | | else if (!hasNull && IsCollectionType(property.PropertyType)) |
| | 57 | 171 | | { |
| | 57 | 172 | | CompareCollections(actualValue!, expectedValue!, options, visitedObjects, propertyPath); |
| | 57 | 173 | | } |
| | 311 | 174 | | else if (!hasNull && actualValue.GetType().IsTypeDefinition) |
| | 219 | 175 | | { |
| | 219 | 176 | | actualValue.ShouldBeEquivalentTo(expectedValue, $"Property {propertyPath} does not match."); |
| | 219 | 177 | | } |
| | 92 | 178 | | else if (!hasNull && IsComplexType(actualValue!)) |
| | 27 | 179 | | { |
| | 27 | 180 | | CompareObjects(actualValue, expectedValue, options, visitedObjects, propertyPath); |
| | 27 | 181 | | } |
| | | 182 | | else |
| | 65 | 183 | | { |
| | 65 | 184 | | actualValue.ShouldBe(expectedValue, $"Property {propertyPath} does not match."); |
| | 65 | 185 | | } |
| | 368 | 186 | | } |
| | | 187 | | |
| | | 188 | | private static void CompareCollections<T>(object actual, object expected, EquivalencyOptions<T> options, HashSet<(ob |
| | 57 | 189 | | { |
| | 57 | 190 | | var actualList = ((IEnumerable<object>)actual).ToList(); |
| | 57 | 191 | | var expectedList = ((IEnumerable<object>)expected).ToList(); |
| | | 192 | | |
| | 57 | 193 | | actualList.Count.ShouldBe(expectedList.Count, "Collection counts do not match."); |
| | | 194 | | |
| | 360 | 195 | | for (int i = 0; i < actualList.Count; i++) |
| | 123 | 196 | | { |
| | 123 | 197 | | CompareObjects(actualList[i], expectedList[i], options, visitedObjects, parentPath); |
| | 123 | 198 | | } |
| | 57 | 199 | | } |
| | | 200 | | |
| | | 201 | | private static bool IsCollectionType(Type type) |
| | 303 | 202 | | { |
| | 303 | 203 | | return typeof(IEnumerable).IsAssignableFrom(type) && type != typeof(string); |
| | 303 | 204 | | } |
| | | 205 | | |
| | | 206 | | private static bool IsComplexType(object obj) |
| | 27 | 207 | | { |
| | 27 | 208 | | var type = obj.GetType(); |
| | 27 | 209 | | return !type.IsPrimitive |
| | 27 | 210 | | && !type.IsEnum |
| | 27 | 211 | | && type != typeof(string) |
| | 27 | 212 | | && type != typeof(decimal) |
| | 27 | 213 | | && type != typeof(DateTime) |
| | 27 | 214 | | && type != typeof(DateTimeOffset); |
| | 27 | 215 | | } |
| | | 216 | | } |