diff --git a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs new file mode 100644 index 0000000000..4750108547 --- /dev/null +++ b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs @@ -0,0 +1,1248 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Runtime.ExceptionServices; + +namespace Microsoft.VisualStudio.TestTools.UnitTesting; + +public sealed partial class Assert +{ + /// + /// Walks two object graphs and reports the first structural difference, if any. + /// + private sealed class EquivalenceComparer + { + // Member info caches keyed by runtime type. + private const int MaxComparisonDepth = 256; + private static readonly ConcurrentDictionary MemberCache = new(); + private static readonly ConcurrentDictionary IEquatableEqualsCache = new(); + private static readonly ConcurrentDictionary IsPrimitiveLikeCache = new(); + private static readonly ConcurrentDictionary EnumerableElementTypeCache = new(); + private static readonly ConcurrentDictionary DictionaryValueTypeCache = new(); + + private readonly bool _strict; + + // Topology maps: once we've paired expected `e` with actual `a`, both maps remember the pairing. + // Re-encountering the same pair short-circuits as a match (cycle handled). Re-encountering one + // side paired with a different counterpart on the other side is a topology mismatch. +#pragma warning disable IDE0028 // Collection initialization can be simplified - target-typed `new` cannot pass the comparer in the same syntactic form expected. + private readonly Dictionary _expectedToActual = new(ReferenceObjectComparer.Instance); + private readonly Dictionary _actualToExpected = new(ReferenceObjectComparer.Instance); +#pragma warning restore IDE0028 + + internal EquivalenceComparer(bool strict) + => _strict = strict; + + internal EquivalenceMismatch? Compare(T? expected, T? actual) + => Compare(expected, actual, typeof(T), path: string.Empty, depth: 0); + + private EquivalenceMismatch? Compare(object? expected, object? actual, Type declaredType, string path, int depth) + { + if (ReferenceEquals(expected, actual)) + { + return null; + } + + if (expected is null || actual is null) + { + return EquivalenceMismatch.NullMismatch(path, expected, actual); + } + + if (depth >= MaxComparisonDepth) + { + return EquivalenceMismatch.MaxDepthExceeded(path, MaxComparisonDepth); + } + + Type expectedRuntimeType = expected.GetType(); + Type actualRuntimeType = actual.GetType(); + + if (expected is Type || actual is Type) + { + return expected is Type && actual is Type + ? Equals(expected, actual) + ? null + : EquivalenceMismatch.ValueMismatch(path, expected, actual) + : EquivalenceMismatch.TypeMismatch(path, expectedRuntimeType, actualRuntimeType); + } + + // Primitive-ish types: trust Equals, but report runtime-type mismatches explicitly when + // boxed through a wider declared type such as object. + if (IsPrimitiveLike(expectedRuntimeType) || IsPrimitiveLike(actualRuntimeType)) + { + return expectedRuntimeType == actualRuntimeType + ? Equals(expected, actual) + ? null + : EquivalenceMismatch.ValueMismatch(path, expected, actual) + : EquivalenceMismatch.TypeMismatch(path, expectedRuntimeType, actualRuntimeType); + } + + // IEquatable shortcut on the declared (compile-time) type, when it is more specific + // than `object`. We deliberately ignore plain object.Equals overrides and only honor + // the explicit IEquatable contract. + if (declaredType != typeof(object)) + { + switch (InvokeIEquatable(expected, actual, declaredType, out Exception? thrownByUser)) + { + case IEquatableOutcome.Equal: + return null; + case IEquatableOutcome.NotEqual: + return EquivalenceMismatch.ValueMismatch(path, expected, actual); + case IEquatableOutcome.Threw: + return EquivalenceMismatch.IEquatableThrew(path, thrownByUser!); + } + } + + // Topology check + cycle guard: we record the pair persistently so that: + // * if we re-enter the same pair (via a cycle), we treat it as a match; + // * if we re-enter with a different counterpart on either side, that's a topology + // mismatch (the two graphs do not have the same shape of references). + // Skip value types: they cannot form reference cycles, and boxing them into the topology + // maps would both miss subsequent visits (each box is a fresh reference) and grow the + // dictionaries unnecessarily. + // These mappings are intentionally not unwound when a deeper comparison later fails. + // Comparison is fail-fast, so a non-null mismatch immediately aborts traversal and the + // comparer instance is never reused to continue from a sibling branch. + if (!expectedRuntimeType.IsValueType && !actualRuntimeType.IsValueType) + { + if (_expectedToActual.TryGetValue(expected, out object? mappedActual)) + { + return ReferenceEquals(mappedActual, actual) + ? null + : EquivalenceMismatch.TopologyMismatch(path); + } + + if (_actualToExpected.TryGetValue(actual, out object? mappedExpected)) + { + return ReferenceEquals(mappedExpected, expected) + ? null + : EquivalenceMismatch.TopologyMismatch(path); + } + + _expectedToActual[expected] = actual; + _actualToExpected[actual] = expected; + } + + bool expectedIsDictionary = TryCreateDictionaryView(expected, out DictionaryView? expectedDict); + bool actualIsDictionary = TryCreateDictionaryView(actual, out DictionaryView? actualDict); + + if (expectedIsDictionary && actualIsDictionary) + { + Type valueDeclaredType = GetDictionaryValueType(declaredType, expectedRuntimeType, actualRuntimeType); + return CompareDictionaries(expectedDict!, actualDict!, valueDeclaredType, path, depth); + } + + // If exactly one side is a dictionary, treat as a structural mismatch rather than + // collapsing into KeyValuePair-by-KeyValuePair enumeration which would be confusing. + if (expectedIsDictionary != actualIsDictionary) + { + return EquivalenceMismatch.TypeMismatch(path, expectedRuntimeType, actualRuntimeType); + } + + if (expected is IEnumerable expectedEnum && actual is IEnumerable actualEnum) + { + Type elementDeclaredType = GetEnumerableElementType(declaredType, expectedRuntimeType, actualRuntimeType); + return CompareEnumerables(expectedEnum, actualEnum, elementDeclaredType, path, depth); + } + + return CompareMembers(expected, actual, expectedRuntimeType, actualRuntimeType, path, depth); + } + + private EquivalenceMismatch? CompareDictionaries(DictionaryView expected, DictionaryView actual, Type valueDeclaredType, string path, int depth) + { + EquivalenceMismatch? failure = ForEachEntry(expected, isExpected: true, path, kvp => + { + string childPath = AppendDictionaryKey(path, kvp.Key); + + EquivalenceMismatch? lookup = SafeDictionaryOp( + actual.TryGetValuePair, + kvp.Key, + isExpected: false, + childPath, + out DictionaryLookup lookupResult); + return lookup is not null + ? lookup + : !lookupResult.Found + ? EquivalenceMismatch.MissingKey(childPath, kvp.Key) + : Compare(kvp.Value, lookupResult.Value, valueDeclaredType, childPath, depth + 1); + }); + if (failure is not null) + { + return failure; + } + + // In strict mode, additional keys on actual are a mismatch. In non-strict mode, they + // are ignored (matching xUnit's `Assert.Equivalent` behaviour and the public docs). + if (_strict) + { + failure = ForEachEntry(actual, isExpected: false, path, kvp => + { + string childPath = AppendDictionaryKey(path, kvp.Key); + EquivalenceMismatch? lookup = SafeDictionaryOp( + expected.ContainsKey, + kvp.Key, + isExpected: true, + childPath, + out bool exists); + return lookup is not null + ? lookup + : exists + ? null + : EquivalenceMismatch.UnexpectedKey(childPath, kvp.Key); + }); + } + + return failure; + } + + /// + /// Iterates the entries of a , catching any user-supplied exceptions + /// thrown by the underlying enumerator and surfacing them as a structured mismatch. + /// + private static EquivalenceMismatch? ForEachEntry(DictionaryView view, bool isExpected, string path, Func, EquivalenceMismatch?> onEntry) + { + IEnumerator>? enumerator; + try + { + enumerator = view.Entries.GetEnumerator(); + } + catch (TargetInvocationException tie) + { + ThrowIfAssertException(tie.InnerException); + return EquivalenceMismatch.DictionaryAccessFailure(path, isExpected, tie.InnerException ?? tie); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not UnitTestAssertException) + { + return EquivalenceMismatch.DictionaryAccessFailure(path, isExpected, ex); + } + + using (enumerator) + { + while (true) + { + KeyValuePair kvp; + try + { + if (!enumerator.MoveNext()) + { + return null; + } + + kvp = enumerator.Current; + } + catch (TargetInvocationException tie) + { + ThrowIfAssertException(tie.InnerException); + return EquivalenceMismatch.DictionaryAccessFailure(path, isExpected, tie.InnerException ?? tie); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not UnitTestAssertException) + { + return EquivalenceMismatch.DictionaryAccessFailure(path, isExpected, ex); + } + + EquivalenceMismatch? nested = onEntry(kvp); + if (nested is not null) + { + return nested; + } + } + } + } + + /// + /// Runs a single dictionary access operation that takes one argument, catching user-supplied + /// exceptions and surfacing them as a structured mismatch. On success, the operation's return + /// value is exposed via . + /// + private static EquivalenceMismatch? SafeDictionaryOp(Func op, TArg arg, bool isExpected, string path, out TResult result) + { + try + { + result = op(arg); + return null; + } + catch (TargetInvocationException tie) + { + ThrowIfAssertException(tie.InnerException); + result = default!; + return EquivalenceMismatch.DictionaryAccessFailure(path, isExpected, tie.InnerException ?? tie); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not UnitTestAssertException) + { + result = default!; + return EquivalenceMismatch.DictionaryAccessFailure(path, isExpected, ex); + } + } + + private EquivalenceMismatch? CompareEnumerables(IEnumerable expected, IEnumerable actual, Type elementDeclaredType, string path, int depth) + { + EquivalenceMismatch? failure = TryGetEnumerator(expected, isExpected: true, path, out IEnumerator expectedEnumerator); + if (failure is not null) + { + return failure; + } + + failure = TryGetEnumerator(actual, isExpected: false, path, out IEnumerator actualEnumerator); + if (failure is not null) + { + DisposeEnumerator(expectedEnumerator); + return failure; + } + + try + { + int index = 0; + while (true) + { + failure = TryMoveNext(expectedEnumerator, isExpected: true, path, out bool expectedHasNext); + if (failure is not null) + { + return failure; + } + + failure = TryMoveNext(actualEnumerator, isExpected: false, path, out bool actualHasNext); + if (failure is not null) + { + return failure; + } + + if (!expectedHasNext || !actualHasNext) + { + if (expectedHasNext != actualHasNext) + { + failure = TryGetEnumerableCount(expectedEnumerator, expectedHasNext, isExpected: true, path, index, out int expectedCount); + if (failure is not null) + { + return failure; + } + + failure = TryGetEnumerableCount(actualEnumerator, actualHasNext, isExpected: false, path, index, out int actualCount); + return failure ?? EquivalenceMismatch.LengthMismatch(path, expectedCount, actualCount); + } + + return null; + } + + failure = TryGetCurrent(expectedEnumerator, isExpected: true, path, out object? expectedItem); + if (failure is not null) + { + return failure; + } + + failure = TryGetCurrent(actualEnumerator, isExpected: false, path, out object? actualItem); + if (failure is not null) + { + return failure; + } + + EquivalenceMismatch? nested = Compare(expectedItem, actualItem, elementDeclaredType, AppendIndex(path, index), depth + 1); + if (nested is not null) + { + return nested; + } + + index++; + } + } + finally + { + DisposeEnumerator(expectedEnumerator); + DisposeEnumerator(actualEnumerator); + } + } + + private EquivalenceMismatch? CompareMembers(object expected, object actual, Type expectedType, Type actualType, string path, int depth) + { + MemberLookup expectedMembers = GetMembers(expectedType); + + // When strict mode is on AND runtime types differ, ensure the actual side declares no extra + // members beyond what's present on expected. + if (_strict && expectedType != actualType) + { + MemberLookup actualMembers = GetMembers(actualType); + + List? extras = null; + foreach (MemberAccessor am in actualMembers.Sorted) + { + if (!expectedMembers.ByName.ContainsKey(am.Name)) + { + (extras ??= []).Add(am.Name); + } + } + + if (extras is { Count: > 0 }) + { + return EquivalenceMismatch.ExtraMembers(path, extras); + } + } + + foreach (MemberAccessor member in expectedMembers.Sorted) + { + MemberAccessor? matchingActual = FindMember(actualType, member.Name); + string childPath = AppendMember(path, member.Name); + + if (matchingActual is null) + { + return EquivalenceMismatch.MissingMember(childPath, member.Name); + } + + object? expectedValue; + object? actualValue; + try + { + expectedValue = member.GetValue(expected); + } + catch (TargetInvocationException ex) + { + ThrowIfAssertException(ex.InnerException); + return EquivalenceMismatch.MemberAccessFailure(childPath, isExpected: true, ex.InnerException ?? ex); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not UnitTestAssertException) + { + return EquivalenceMismatch.MemberAccessFailure(childPath, isExpected: true, ex); + } + + try + { + actualValue = matchingActual.GetValue(actual); + } + catch (TargetInvocationException ex) + { + ThrowIfAssertException(ex.InnerException); + return EquivalenceMismatch.MemberAccessFailure(childPath, isExpected: false, ex.InnerException ?? ex); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not UnitTestAssertException) + { + return EquivalenceMismatch.MemberAccessFailure(childPath, isExpected: false, ex); + } + + EquivalenceMismatch? nested = Compare(expectedValue, actualValue, member.MemberType, childPath, depth + 1); + if (nested is not null) + { + return nested; + } + } + + return null; + } + + private static EquivalenceMismatch? TryGetEnumerator(IEnumerable source, bool isExpected, string path, out IEnumerator enumerator) + { + try + { + enumerator = source.GetEnumerator(); + return null; + } + catch (TargetInvocationException tie) + { + ThrowIfAssertException(tie.InnerException); + enumerator = default!; + return EquivalenceMismatch.EnumerationFailure(path, isExpected, tie.InnerException ?? tie); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not UnitTestAssertException) + { + enumerator = default!; + return EquivalenceMismatch.EnumerationFailure(path, isExpected, ex); + } + } + + private static EquivalenceMismatch? TryMoveNext(IEnumerator enumerator, bool isExpected, string path, out bool hasNext) + { + try + { + hasNext = enumerator.MoveNext(); + return null; + } + catch (TargetInvocationException tie) + { + ThrowIfAssertException(tie.InnerException); + hasNext = false; + return EquivalenceMismatch.EnumerationFailure(path, isExpected, tie.InnerException ?? tie); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not UnitTestAssertException) + { + hasNext = false; + return EquivalenceMismatch.EnumerationFailure(path, isExpected, ex); + } + } + + private static EquivalenceMismatch? TryGetCurrent(IEnumerator enumerator, bool isExpected, string path, out object? current) + { + try + { + current = enumerator.Current; + return null; + } + catch (TargetInvocationException tie) + { + ThrowIfAssertException(tie.InnerException); + current = default; + return EquivalenceMismatch.EnumerationFailure(path, isExpected, tie.InnerException ?? tie); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not UnitTestAssertException) + { + current = default; + return EquivalenceMismatch.EnumerationFailure(path, isExpected, ex); + } + } + + private static EquivalenceMismatch? TryGetEnumerableCount(IEnumerator enumerator, bool hasCurrent, bool isExpected, string path, int matchedItemCount, out int count) + { + count = matchedItemCount; + if (!hasCurrent) + { + return null; + } + + EquivalenceMismatch? failure = TryGetCurrent(enumerator, isExpected, path, out _); + if (failure is not null) + { + count = 0; + return failure; + } + + count++; + while (true) + { + failure = TryMoveNext(enumerator, isExpected, path, out bool hasNext); + if (failure is not null) + { + count = 0; + return failure; + } + + if (!hasNext) + { + return null; + } + + failure = TryGetCurrent(enumerator, isExpected, path, out _); + if (failure is not null) + { + count = 0; + return failure; + } + + count++; + } + } + + private static void DisposeEnumerator(IEnumerator enumerator) + { + if (enumerator is IDisposable disposable) + { + disposable.Dispose(); + } + } + + private enum IEquatableOutcome + { + NotApplicable, + Equal, + NotEqual, + Threw, + } + + private static IEquatableOutcome InvokeIEquatable(object expected, object actual, Type declaredType, out Exception? thrown) + { + thrown = null; + MethodInfo? equalsMethod = IEquatableEqualsCache.GetOrAdd(declaredType, GetIEquatableEqualsMethod); + if (equalsMethod is null) + { + return IEquatableOutcome.NotApplicable; + } + + // Both sides must be assignable to declaredType for the call to be safe. Since the method + // came in via the static type, this is true at the public entry point. For nested members + // (and collection elements), we resolve the method against the *declared* property/field/element + // type, but the runtime values can be derived types. As long as actual is also assignable, we + // can still call. + if (!declaredType.IsInstanceOfType(expected) || !declaredType.IsInstanceOfType(actual)) + { + return IEquatableOutcome.NotApplicable; + } + + try + { + bool isEqual = (bool)equalsMethod.Invoke(expected, [actual])!; + return isEqual ? IEquatableOutcome.Equal : IEquatableOutcome.NotEqual; + } + catch (TargetInvocationException ex) + { + ThrowIfAssertException(ex.InnerException); + thrown = ex.InnerException ?? ex; + return IEquatableOutcome.Threw; + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not UnitTestAssertException) + { + thrown = ex; + return IEquatableOutcome.Threw; + } + } + + private static MethodInfo? GetIEquatableEqualsMethod(Type declaredType) + { + if (declaredType == typeof(object) || declaredType == typeof(string)) + { + // string is already handled via the primitive-like path; skip the IEquatable lookup. + return null; + } + + Type ieq; + try + { + ieq = typeof(IEquatable<>).MakeGenericType(declaredType); + } + catch (ArgumentException) + { + return null; + } + + if (!ieq.IsAssignableFrom(declaredType)) + { + return null; + } + + // Resolve the IEquatable.Equals(T) method on the interface itself. Invoking via reflection + // performs virtual/interface dispatch, so this finds both implicit and explicit interface + // implementations on the actual instance. + return ieq.GetMethod("Equals", [declaredType]); + } + + private static Type GetEnumerableElementType(Type declaredType, Type expectedRuntimeType, Type actualRuntimeType) + => TryGetEnumerableElementType(declaredType) + ?? TryGetEnumerableElementType(expectedRuntimeType) + ?? TryGetEnumerableElementType(actualRuntimeType) + ?? typeof(object); + + private static Type? TryGetEnumerableElementType(Type type) + => EnumerableElementTypeCache.GetOrAdd(type, static t => + { + if (t.IsArray) + { + return t.GetElementType(); + } + + if (t.IsConstructedGenericType && t.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + return t.GetGenericArguments()[0]; + } + + Type? best = null; + foreach (Type i in t.GetInterfaces()) + { + if (i.IsConstructedGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + Type elem = i.GetGenericArguments()[0]; + + // If multiple IEnumerable are implemented, prefer the most specific (non-object) one. + if (best is null || best == typeof(object)) + { + best = elem; + } + } + } + + return best; + }); + + private static Type GetDictionaryValueType(Type declaredType, Type expectedRuntimeType, Type actualRuntimeType) + => TryGetDictionaryValueType(declaredType) + ?? TryGetDictionaryValueType(expectedRuntimeType) + ?? TryGetDictionaryValueType(actualRuntimeType) + ?? typeof(object); + + private static Type? TryGetDictionaryValueType(Type type) + => DictionaryValueTypeCache.GetOrAdd(type, static t => + { + if (t.IsConstructedGenericType) + { + Type def = t.GetGenericTypeDefinition(); + if (def == typeof(IDictionary<,>) || def == typeof(IReadOnlyDictionary<,>)) + { + return t.GetGenericArguments()[1]; + } + } + + foreach (Type i in t.GetInterfaces()) + { + if (i.IsConstructedGenericType) + { + Type def = i.GetGenericTypeDefinition(); + if (def == typeof(IDictionary<,>) || def == typeof(IReadOnlyDictionary<,>)) + { + return i.GetGenericArguments()[1]; + } + } + } + + return null; + }); + + private static bool IsPrimitiveLike(Type type) + => IsPrimitiveLikeCache.GetOrAdd(type, static t => + { + if (t.IsPrimitive || t.IsEnum) + { + return true; + } + + if (t == typeof(string) || t == typeof(decimal) || + t == typeof(DateTime) || t == typeof(DateTimeOffset) || + t == typeof(TimeSpan) || t == typeof(Guid) || + t == typeof(Uri) || t == typeof(Type) || + t == typeof(Version)) + { + return true; + } + + // DateOnly / TimeOnly / Half / Int128 exist only on newer TFMs; match by full name to avoid #if. + string? fullName = t.FullName; + return fullName is "System.DateOnly" or "System.TimeOnly" or "System.Half" or "System.Int128" or "System.UInt128"; + }); + + /// + /// Rethrows a framework assertion exception (e.g., from a user-defined property getter or + /// IEquatable.Equals that called Assert.Fail) unwrapped from , + /// so user assertions propagate untouched instead of being rewritten as a structured equivalence failure. + /// Uses to preserve the original throw site in the stack trace. + /// + private static void ThrowIfAssertException(Exception? inner) + { + if (inner is UnitTestAssertException) + { + ExceptionDispatchInfo.Capture(inner).Throw(); + } + } + + private static bool TryCreateDictionaryView(object value, out DictionaryView? view) + { + // Non-generic IDictionary takes precedence over IDictionary<,> / IReadOnlyDictionary<,>: + // most BCL dictionaries implement both and route both surfaces to the same backing store + // (so the choice is observationally equivalent), while custom hybrid types are expected + // to keep their non-generic surface consistent with the generic one. Picking the non- + // generic path first avoids reflection-based dispatch where possible. + if (value is IDictionary nonGeneric) + { + view = new NonGenericDictionaryView(nonGeneric); + return true; + } + + // Look for an IDictionary implementation first; fall back to + // IReadOnlyDictionary if only the read-only surface is present. + // When a type implements both (which is the case for most BCL dictionaries), the mutating + // contract wins deterministically — that's the same precedence System.Collections.Generic.Dictionary + // expresses, and it lets users override semantics by implementing IDictionary<,> explicitly. + Type? dictInterface = null; + Type? readOnlyDictInterface = null; + foreach (Type i in value.GetType().GetInterfaces()) + { + if (!i.IsGenericType) + { + continue; + } + + Type def = i.GetGenericTypeDefinition(); + if (def == typeof(IDictionary<,>)) + { + dictInterface = i; + break; + } + + if (def == typeof(IReadOnlyDictionary<,>)) + { + readOnlyDictInterface = i; + } + } + + Type? selected = dictInterface ?? readOnlyDictInterface; + if (selected is not null) + { + view = GenericDictionaryView.Create(value, selected); + return true; + } + + view = null; + return false; + } + + private static MemberLookup GetMembers(Type type) + => MemberCache.GetOrAdd(type, static t => + { + // Collect candidates per name, preferring the most-derived declaration so that + // `new`-shadowed properties/fields are deterministically resolved to the most-derived + // member regardless of metadata ordering. +#pragma warning disable IDE0028 // Collection initialization can be simplified — target-typed `new` cannot pass the comparer in the same syntactic form expected. + Dictionary byName = new(StringComparer.Ordinal); + Dictionary declaringTypes = new(StringComparer.Ordinal); +#pragma warning restore IDE0028 + + foreach (PropertyInfo p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!p.CanRead || p.GetIndexParameters().Length > 0) + { + continue; + } + + MethodInfo? getter = p.GetGetMethod(nonPublic: false); + if (getter is null) + { + continue; + } + + TryRegisterMostDerived(byName, declaringTypes, p, new MemberAccessor(p.Name, p.PropertyType, p)); + } + + foreach (FieldInfo f in t.GetFields(BindingFlags.Public | BindingFlags.Instance)) + { + if (f.IsStatic) + { + continue; + } + + TryRegisterMostDerived(byName, declaringTypes, f, new MemberAccessor(f.Name, f.FieldType, f)); + } + + var sorted = new MemberAccessor[byName.Count]; + int i = 0; + foreach (MemberAccessor accessor in byName.Values) + { + sorted[i++] = accessor; + } + + Array.Sort(sorted, static (a, b) => StringComparer.Ordinal.Compare(a.Name, b.Name)); + + return new MemberLookup(sorted, byName); + }); + + private static void TryRegisterMostDerived(Dictionary byName, Dictionary declaringTypes, MemberInfo member, MemberAccessor accessor) + { + if (byName.ContainsKey(member.Name) + && !IsMoreDerivedThan(member.DeclaringType, declaringTypes[member.Name])) + { + return; + } + + byName[member.Name] = accessor; + declaringTypes[member.Name] = member.DeclaringType ?? typeof(object); + } + + private static bool IsMoreDerivedThan(Type? candidate, Type? incumbent) + => candidate is not null + && incumbent is not null + && candidate != incumbent + && incumbent.IsAssignableFrom(candidate); + + private static MemberAccessor? FindMember(Type type, string name) + => GetMembers(type).ByName.TryGetValue(name, out MemberAccessor? found) ? found : null; + + private static string AppendMember(string parent, string name) + => parent.Length == 0 ? name : $"{parent}.{name}"; + + private static string AppendIndex(string parent, int index) + => $"{parent}[{index.ToString(CultureInfo.InvariantCulture)}]"; + + private static string AppendDictionaryKey(string parent, object key) + { + string keyPart = $"[{AssertionValueRenderer.RenderValue(key)}]"; + return parent.Length == 0 ? keyPart : parent + keyPart; + } + } + + /// + /// Reference-equality comparer used for keys in the topology maps of . + /// + private sealed class ReferenceObjectComparer : IEqualityComparer + { + internal static readonly ReferenceObjectComparer Instance = new(); + + private ReferenceObjectComparer() + { + } + + bool IEqualityComparer.Equals(object? x, object? y) => ReferenceEquals(x, y); + + int IEqualityComparer.GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj); + } + + /// + /// Cached set of public instance properties and fields for a type, with both an alphabetically-sorted + /// array (for deterministic traversal) and a name-keyed dictionary (for O(1) lookup). + /// + private sealed class MemberLookup + { + internal MemberLookup(MemberAccessor[] sorted, IReadOnlyDictionary byName) + { + Sorted = sorted; + ByName = byName; + } + + internal MemberAccessor[] Sorted { get; } + + internal IReadOnlyDictionary ByName { get; } + } + + /// + /// Abstraction over both and generic + /// / instances. + /// Routes lookups back to the source dictionary so the source's own + /// for keys is preserved. + /// + private abstract class DictionaryView + { + internal abstract IEnumerable> Entries { get; } + + internal abstract bool TryGetValue(object key, out object? value); + + internal abstract bool ContainsKey(object key); + + /// + /// Variant of for use from contexts that can't + /// capture an out parameter (e.g., lambdas). + /// + internal DictionaryLookup TryGetValuePair(object key) + { + bool found = TryGetValue(key, out object? value); + return new DictionaryLookup(found, value); + } + } + + private readonly struct DictionaryLookup + { + internal DictionaryLookup(bool found, object? value) + { + Found = found; + Value = value; + } + + internal bool Found { get; } + + internal object? Value { get; } + } + + private sealed class NonGenericDictionaryView : DictionaryView + { + private readonly IDictionary _source; + + internal NonGenericDictionaryView(IDictionary source) => _source = source; + + internal override IEnumerable> Entries + { + get + { + IDictionaryEnumerator e = _source.GetEnumerator(); + try + { + while (e.MoveNext()) + { + DictionaryEntry entry = e.Entry; + yield return new KeyValuePair(entry.Key, entry.Value); + } + } + finally + { + (e as IDisposable)?.Dispose(); + } + } + } + + internal override bool ContainsKey(object key) => _source.Contains(key); + + internal override bool TryGetValue(object key, out object? value) + { + if (!_source.Contains(key)) + { + value = null; + return false; + } + + value = _source[key]; + return true; + } + } + + private sealed class GenericDictionaryView : DictionaryView + { + private static readonly ConcurrentDictionary AccessorCache = new(); + + private readonly object _source; + private readonly GenericDictionaryAccessors _accessors; + + private GenericDictionaryView(object source, GenericDictionaryAccessors accessors) + { + _source = source; + _accessors = accessors; + } + + internal static GenericDictionaryView Create(object source, Type dictionaryInterface) + => new(source, AccessorCache.GetOrAdd(dictionaryInterface, GenericDictionaryAccessors.Build)); + + internal override IEnumerable> Entries + => _accessors.Enumerate(_source); + + internal override bool ContainsKey(object key) + => _accessors.KeyType.IsInstanceOfType(key) && _accessors.ContainsKey(_source, key); + + internal override bool TryGetValue(object key, out object? value) + { + if (!_accessors.KeyType.IsInstanceOfType(key)) + { + value = null; + return false; + } + + return _accessors.TryGetValue(_source, key, out value); + } + } + + /// + /// Per-generic-dictionary-interface set of cached reflection accessors. Routes lookups to the + /// source's native methods (TryGetValue, ContainsKey) so the source's + /// is honored. + /// + private sealed class GenericDictionaryAccessors + { + private readonly MethodInfo _tryGetValueMethod; + private readonly MethodInfo _containsKeyMethod; + private readonly PropertyInfo _kvpKey; + private readonly PropertyInfo _kvpValue; + + private GenericDictionaryAccessors(Type keyType, MethodInfo tryGetValueMethod, MethodInfo containsKeyMethod, PropertyInfo kvpKey, PropertyInfo kvpValue) + { + KeyType = keyType; + _tryGetValueMethod = tryGetValueMethod; + _containsKeyMethod = containsKeyMethod; + _kvpKey = kvpKey; + _kvpValue = kvpValue; + } + + internal Type KeyType { get; } + + internal static GenericDictionaryAccessors Build(Type dictionaryInterface) + { + Type[] args = dictionaryInterface.GetGenericArguments(); + Type keyType = args[0]; + Type valueType = args[1]; + + MethodInfo tryGet = dictionaryInterface.GetMethod("TryGetValue")!; + MethodInfo contains = dictionaryInterface.GetMethod("ContainsKey")!; + + Type kvpType = typeof(KeyValuePair<,>).MakeGenericType(keyType, valueType); + PropertyInfo keyProp = kvpType.GetProperty("Key")!; + PropertyInfo valueProp = kvpType.GetProperty("Value")!; + + return new GenericDictionaryAccessors(keyType, tryGet, contains, keyProp, valueProp); + } + + internal IEnumerable> Enumerate(object source) + { + foreach (object? item in (IEnumerable)source) + { + // KeyValuePair<,> is a value type, so `item` cannot be null when iterated as object; + // we still dereference defensively in case a custom IEnumerable yields a boxed null. + if (item is null) + { + continue; + } + + object key = _kvpKey.GetValue(item)!; + object? val = _kvpValue.GetValue(item); + yield return new KeyValuePair(key, val); + } + } + + internal bool ContainsKey(object source, object key) + => (bool)_containsKeyMethod.Invoke(source, [key])!; + + internal bool TryGetValue(object source, object key, out object? value) + { + object?[] args = [key, null]; + bool found = (bool)_tryGetValueMethod.Invoke(source, args)!; + value = args[1]; + return found; + } + } + + /// + /// Describes how to read a single member (property or field) from an instance. + /// + private sealed class MemberAccessor + { + private readonly PropertyInfo? _property; + private readonly FieldInfo? _field; + + internal MemberAccessor(string name, Type memberType, PropertyInfo property) + { + Name = name; + MemberType = memberType; + _property = property; + } + + internal MemberAccessor(string name, Type memberType, FieldInfo field) + { + Name = name; + MemberType = memberType; + _field = field; + } + + internal string Name { get; } + + internal Type MemberType { get; } + + internal object? GetValue(object instance) + => _property is not null ? _property.GetValue(instance) : _field!.GetValue(instance); + } + + /// + /// A single structural mismatch found by , carrying the dotted + /// member path, a localized reason summary, and any expected/actual snippets to render. + /// + private sealed class EquivalenceMismatch + { + private EquivalenceMismatch(string path, string reason, string? expectedText, string? actualText, bool isComparisonFailure) + { + Path = path; + Reason = reason; + ExpectedText = expectedText; + ActualText = actualText; + IsComparisonFailure = isComparisonFailure; + } + + internal string Path { get; } + + internal string Reason { get; } + + internal string? ExpectedText { get; } + + internal string? ActualText { get; } + + internal bool IsComparisonFailure { get; } + + internal static EquivalenceMismatch ValueMismatch(string path, object? expected, object? actual) + => new( + path, + FrameworkMessages.AreEquivalentMismatchValue, + AssertionValueRenderer.RenderValue(expected), + AssertionValueRenderer.RenderValue(actual), + isComparisonFailure: false); + + internal static EquivalenceMismatch NullMismatch(string path, object? expected, object? actual) + => new( + path, + FrameworkMessages.AreEquivalentMismatchNull, + AssertionValueRenderer.RenderValue(expected), + AssertionValueRenderer.RenderValue(actual), + isComparisonFailure: false); + + internal static EquivalenceMismatch TypeMismatch(string path, Type expectedType, Type actualType) + => new( + path, + string.Format(CultureInfo.CurrentCulture, FrameworkMessages.AreEquivalentMismatchType, expectedType.FullName ?? expectedType.Name, actualType.FullName ?? actualType.Name), + expectedType.FullName ?? expectedType.Name, + actualType.FullName ?? actualType.Name, + isComparisonFailure: false); + + internal static EquivalenceMismatch TopologyMismatch(string path) + => new( + path, + FrameworkMessages.AreEquivalentMismatchTopology, + expectedText: null, + actualText: null, + isComparisonFailure: false); + + internal static EquivalenceMismatch LengthMismatch(string path, int expectedCount, int actualCount) + => new( + path, + string.Format(CultureInfo.CurrentCulture, FrameworkMessages.AreEquivalentMismatchLength, expectedCount, actualCount), + expectedCount.ToString(CultureInfo.InvariantCulture), + actualCount.ToString(CultureInfo.InvariantCulture), + isComparisonFailure: false); + + internal static EquivalenceMismatch MissingKey(string path, object key) + => new( + path, + string.Format(CultureInfo.CurrentCulture, FrameworkMessages.AreEquivalentMismatchMissingKey, AssertionValueRenderer.RenderValue(key)), + expectedText: null, + actualText: null, + isComparisonFailure: false); + + internal static EquivalenceMismatch UnexpectedKey(string path, object key) + => new( + path, + string.Format(CultureInfo.CurrentCulture, FrameworkMessages.AreEquivalentMismatchUnexpectedKey, AssertionValueRenderer.RenderValue(key)), + expectedText: null, + actualText: null, + isComparisonFailure: false); + + internal static EquivalenceMismatch MissingMember(string path, string memberName) + => new( + path, + string.Format(CultureInfo.CurrentCulture, FrameworkMessages.AreEquivalentMismatchMissingMember, memberName), + expectedText: null, + actualText: null, + isComparisonFailure: false); + + internal static EquivalenceMismatch ExtraMembers(string path, IReadOnlyList extras) + => new( + path, + string.Format(CultureInfo.CurrentCulture, FrameworkMessages.AreEquivalentMismatchExtraMembers, string.Join(", ", extras)), + expectedText: null, + actualText: null, + isComparisonFailure: false); + + internal static EquivalenceMismatch IEquatableThrew(string path, Exception thrown) + => new( + path, + string.Format( + CultureInfo.CurrentCulture, + FrameworkMessages.AreEquivalentMismatchIEquatableThrew, + thrown.GetType().Name, + thrown.Message), + expectedText: null, + actualText: null, + isComparisonFailure: true); + + internal static EquivalenceMismatch DictionaryAccessFailure(string path, bool isExpected, Exception inner) + => new( + path, + string.Format( + CultureInfo.CurrentCulture, + isExpected ? FrameworkMessages.AreEquivalentMismatchExpectedDictionaryThrew : FrameworkMessages.AreEquivalentMismatchActualDictionaryThrew, + inner.GetType().Name, + inner.Message), + expectedText: null, + actualText: null, + isComparisonFailure: true); + + internal static EquivalenceMismatch EnumerationFailure(string path, bool isExpected, Exception inner) + => new( + path, + string.Format( + CultureInfo.CurrentCulture, + isExpected ? FrameworkMessages.AreEquivalentMismatchExpectedEnumerationThrew : FrameworkMessages.AreEquivalentMismatchActualEnumerationThrew, + inner.GetType().Name, + inner.Message), + expectedText: null, + actualText: null, + isComparisonFailure: true); + + internal static EquivalenceMismatch MemberAccessFailure(string path, bool isExpected, Exception inner) + => new( + path, + string.Format( + CultureInfo.CurrentCulture, + isExpected ? FrameworkMessages.AreEquivalentMismatchExpectedMemberThrew : FrameworkMessages.AreEquivalentMismatchActualMemberThrew, + inner.GetType().Name, + inner.Message), + expectedText: null, + actualText: null, + isComparisonFailure: true); + + internal static EquivalenceMismatch MaxDepthExceeded(string path, int maxDepth) + => new( + path, + string.Format(CultureInfo.CurrentCulture, FrameworkMessages.AreEquivalentMismatchMaxDepth, maxDepth), + expectedText: null, + actualText: null, + isComparisonFailure: true); + } +} diff --git a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs new file mode 100644 index 0000000000..25b80880ec --- /dev/null +++ b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs @@ -0,0 +1,347 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.VisualStudio.TestTools.UnitTesting; + +public sealed partial class Assert +{ +#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters +#pragma warning disable RS0027 // API with optional parameter(s) should have the most parameters amongst its public overloads + + /// + /// Tests whether two object graphs are structurally equivalent (deep equality) and throws an + /// exception if they are not. + /// + /// + /// The type of values to compare. + /// + /// + /// The first value to compare. This is the value the test expects. + /// + /// + /// The second value to compare. This is the value produced by the code under test. + /// + /// + /// The message to include in the exception when + /// is not equivalent to . The message is shown in + /// test results. + /// + /// + /// The syntactic expression of expected as given by the compiler via caller argument expression. + /// Users shouldn't pass a value for this parameter. + /// + /// + /// The syntactic expression of actual as given by the compiler via caller argument expression. + /// Users shouldn't pass a value for this parameter. + /// + /// + /// Thrown if and are not structurally equivalent. + /// + /// + /// + /// This assertion performs a deep, order-sensitive structural comparison of two object graphs. + /// It differs from , + /// which checks only top-level collection equivalence without regard to element order. + /// + /// + /// The comparison rules are: + /// + /// + /// + /// + /// Reference-equal values (including both ) are considered equivalent. + /// + /// + /// + /// + /// Primitive-like types (numerics, , , , + /// enums, , , , + /// , , , ) are compared with + /// . On target frameworks that ship them, + /// DateOnly, TimeOnly, Half, Int128, and UInt128 are also + /// treated as primitive-like. + /// + /// + /// + /// + /// If the static (compile-time) type of a value implements for itself, + /// is used and recursion stops at that point. + /// Plain overrides are ignored — the comparer always + /// recurses into members for those. + /// + /// + /// + /// + /// Dictionaries (, , or + /// ) are compared by key set, with values compared + /// recursively. Lookups are routed through the source dictionary so its + /// for keys is preserved. + /// + /// + /// + /// + /// Other enumerables (excluding ) are compared element-by-element in the + /// iteration order. Comparison is order-sensitive; if order is irrelevant, use + /// + /// on the collection itself. + /// + /// + /// + /// + /// Other reference types are compared by recursing into all public instance properties (with a public + /// getter and no index parameters) and public instance fields. Static, non-public, and indexed members + /// are ignored. + /// + /// + /// + /// + /// Reference cycles are tracked and traversed at most once per (expected, actual) pair. The same + /// mechanism enforces graph topology: if the same reference on one side is paired with different + /// references on the other side, the assertion fails. + /// + /// + /// + /// + /// If a user-defined , member getter, or dictionary + /// access (TryGetValue, ContainsKey, or enumeration) throws while being + /// evaluated, the assertion fails with a message describing the exception type and message. + /// + /// + /// + /// + /// Comparison stops after a bounded recursion depth to avoid + /// on unusually deep object graphs, and reports that condition as an assertion failure. + /// + /// + /// + /// + public static void AreEquivalent(T? expected, T? actual, string? message = "", [CallerArgumentExpression(nameof(expected))] string expectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "") + => AreEquivalent(expected, actual, strict: false, message, expectedExpression, actualExpression); + + /// + /// Tests whether two object graphs are structurally equivalent (deep equality) and throws an + /// exception if they are not. + /// + /// + /// The type of values to compare. + /// + /// + /// The first value to compare. This is the value the test expects. + /// + /// + /// The second value to compare. This is the value produced by the code under test. + /// + /// + /// When , the comparison fails if declares any + /// public properties or fields that are not present on 's runtime type, + /// or if any extra dictionary keys are present on . When + /// (the default), additional members and dictionary keys on are ignored. + /// When the runtime types of and are identical, + /// this flag has no effect on the public-member comparison, but it still rejects extra dictionary keys + /// on when comparing dictionaries. + /// + /// + /// The message to include in the exception when + /// is not equivalent to . The message is shown in + /// test results. + /// + /// + /// The syntactic expression of expected as given by the compiler via caller argument expression. + /// Users shouldn't pass a value for this parameter. + /// + /// + /// The syntactic expression of actual as given by the compiler via caller argument expression. + /// Users shouldn't pass a value for this parameter. + /// + /// + /// Thrown if and are not structurally equivalent. + /// + public static void AreEquivalent(T? expected, T? actual, bool strict, string? message = "", [CallerArgumentExpression(nameof(expected))] string expectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "") + { + EquivalenceComparer comparer = new(strict); + EquivalenceMismatch? mismatch = comparer.Compare(expected, actual); + if (mismatch is null) + { + return; + } + + ReportAssertAreEquivalentFailed(mismatch, strict, message, expectedExpression, actualExpression); + } + + /// + /// Tests whether two object graphs are NOT structurally equivalent and throws an exception if they are. + /// + /// + /// The type of values to compare. + /// + /// + /// The first value to compare. This is the value the test expects not to match + /// . + /// + /// + /// The second value to compare. This is the value produced by the code under test. + /// + /// + /// The message to include in the exception when + /// is equivalent to . The message is shown in + /// test results. + /// + /// + /// The syntactic expression of notExpected as given by the compiler via caller argument expression. + /// Users shouldn't pass a value for this parameter. + /// + /// + /// The syntactic expression of actual as given by the compiler via caller argument expression. + /// Users shouldn't pass a value for this parameter. + /// + /// + /// Thrown if and are structurally equivalent, + /// or if the structural comparison cannot be completed. + /// + /// + /// See and + /// for the full set of comparison rules. + /// + public static void AreNotEquivalent(T? notExpected, T? actual, string? message = "", [CallerArgumentExpression(nameof(notExpected))] string notExpectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "") + => AreNotEquivalent(notExpected, actual, strict: false, message, notExpectedExpression, actualExpression); + + /// + /// Tests whether two object graphs are NOT structurally equivalent and throws an exception if they are. + /// + /// + /// The type of values to compare. + /// + /// + /// The first value to compare. This is the value the test expects not to match + /// . + /// + /// + /// The second value to compare. This is the value produced by the code under test. + /// + /// + /// When , the equivalence check used to decide whether the assertion fails is + /// performed in strict mode (extra members or dictionary keys on are + /// considered a difference, so the assertion succeeds in their presence). + /// + /// + /// The message to include in the exception when + /// is equivalent to . The message is shown in + /// test results. + /// + /// + /// The syntactic expression of notExpected as given by the compiler via caller argument expression. + /// Users shouldn't pass a value for this parameter. + /// + /// + /// The syntactic expression of actual as given by the compiler via caller argument expression. + /// Users shouldn't pass a value for this parameter. + /// + /// + /// Thrown if and are structurally equivalent, + /// or if the structural comparison cannot be completed. + /// + public static void AreNotEquivalent(T? notExpected, T? actual, bool strict, string? message = "", [CallerArgumentExpression(nameof(notExpected))] string notExpectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "") + { + EquivalenceComparer comparer = new(strict); + EquivalenceMismatch? mismatch = comparer.Compare(notExpected, actual); + if (mismatch is null) + { + ReportAssertAreNotEquivalentFailed(notExpected, actual, strict, message, notExpectedExpression, actualExpression); + } + else if (mismatch.IsComparisonFailure) + { + ReportAssertAreNotEquivalentComparisonFailed(mismatch, strict, message, notExpectedExpression, actualExpression); + } + } + + [DoesNotReturn] + private static void ReportAssertAreEquivalentFailed(EquivalenceMismatch mismatch, bool strict, string? userMessage, string expectedExpression, string actualExpression) + { + string summary = strict + ? FrameworkMessages.AreEquivalentFailedSummaryStrict + : FrameworkMessages.AreEquivalentFailedSummary; + + ReportAssertEquivalenceMismatch( + mismatch, + summary, + userMessage, + "Assert.AreEquivalent", + expectedExpression, + actualExpression, + "", + "", + "expected:", + "actual:"); + } + + [DoesNotReturn] + private static void ReportAssertAreNotEquivalentComparisonFailed(EquivalenceMismatch mismatch, bool strict, string? userMessage, string notExpectedExpression, string actualExpression) + { + string summary = strict + ? FrameworkMessages.AreNotEquivalentComparisonFailedSummaryStrict + : FrameworkMessages.AreNotEquivalentComparisonFailedSummary; + + ReportAssertEquivalenceMismatch( + mismatch, + summary, + userMessage, + "Assert.AreNotEquivalent", + notExpectedExpression, + actualExpression, + "", + "", + "not expected:", + "actual:"); + } + + [DoesNotReturn] + private static void ReportAssertEquivalenceMismatch(EquivalenceMismatch mismatch, string summary, string? userMessage, string assertionMethodName, string leftExpression, string rightExpression, string leftPlaceholder, string rightPlaceholder, string leftLabel, string rightLabel) + { + string locationLine = string.Format( + CultureInfo.CurrentCulture, + FrameworkMessages.AreEquivalentMismatchAt, + mismatch.Path.Length == 0 ? FrameworkMessages.AreEquivalentRootPath : mismatch.Path, + mismatch.Reason); + + StructuredAssertionMessage structured = new(summary); + structured.WithAdditionalSummaryLine(locationLine); + structured.WithUserMessage(userMessage); + + if (mismatch.ExpectedText is not null && mismatch.ActualText is not null) + { + EvidenceBlock evidence = EvidenceBlock.Create() + .AddLine(leftLabel, mismatch.ExpectedText) + .AddLine(rightLabel, mismatch.ActualText); + structured.WithEvidence(evidence); + structured.WithExpectedAndActual(mismatch.ExpectedText, mismatch.ActualText); + } + + structured.WithCallSiteExpression(FormatCallSiteExpression(assertionMethodName, leftExpression, rightExpression, leftPlaceholder, rightPlaceholder)); + + ReportAssertFailed(structured); + } + + [DoesNotReturn] + private static void ReportAssertAreNotEquivalentFailed(T? notExpected, T? actual, bool strict, string? userMessage, string notExpectedExpression, string actualExpression) + { + string summary = strict + ? FrameworkMessages.AreNotEquivalentFailedSummaryStrict + : FrameworkMessages.AreNotEquivalentFailedSummary; + + string actualText = AssertionValueRenderer.RenderValue(actual); + string notExpectedText = AssertionValueRenderer.RenderValue(notExpected); + + StructuredAssertionMessage structured = new(summary); + structured.WithUserMessage(userMessage); + + EvidenceBlock evidence = EvidenceBlock.Create() + .AddLine("not expected:", notExpectedText) + .AddLine("actual:", actualText); + structured.WithEvidence(evidence); + structured.WithExpectedAndActual($"not equivalent to {notExpectedText}", actualText); + + structured.WithCallSiteExpression(FormatCallSiteExpression("Assert.AreNotEquivalent", notExpectedExpression, actualExpression, "", "")); + + ReportAssertFailed(structured); + } +} diff --git a/src/TestFramework/TestFramework/PublicAPI/PublicAPI.Unshipped.txt b/src/TestFramework/TestFramework/PublicAPI/PublicAPI.Unshipped.txt index cc143fac2e..b6e5dde3df 100644 --- a/src/TestFramework/TestFramework/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/TestFramework/TestFramework/PublicAPI/PublicAPI.Unshipped.txt @@ -2,3 +2,7 @@ [MSTESTEXP]static Microsoft.VisualStudio.TestTools.UnitTesting.Assert.Scope() -> System.IDisposable! Microsoft.VisualStudio.TestTools.UnitTesting.AssertFailedException.ActualText.get -> string? Microsoft.VisualStudio.TestTools.UnitTesting.AssertFailedException.ExpectedText.get -> string? +static Microsoft.VisualStudio.TestTools.UnitTesting.Assert.AreEquivalent(T? expected, T? actual, bool strict, string? message = "", string! expectedExpression = "", string! actualExpression = "") -> void +static Microsoft.VisualStudio.TestTools.UnitTesting.Assert.AreEquivalent(T? expected, T? actual, string? message = "", string! expectedExpression = "", string! actualExpression = "") -> void +static Microsoft.VisualStudio.TestTools.UnitTesting.Assert.AreNotEquivalent(T? notExpected, T? actual, bool strict, string? message = "", string! notExpectedExpression = "", string! actualExpression = "") -> void +static Microsoft.VisualStudio.TestTools.UnitTesting.Assert.AreNotEquivalent(T? notExpected, T? actual, string? message = "", string! notExpectedExpression = "", string! actualExpression = "") -> void diff --git a/src/TestFramework/TestFramework/Resources/FrameworkMessages.resx b/src/TestFramework/TestFramework/Resources/FrameworkMessages.resx index 691f3edab7..9abd2ca3a9 100644 --- a/src/TestFramework/TestFramework/Resources/FrameworkMessages.resx +++ b/src/TestFramework/TestFramework/Resources/FrameworkMessages.resx @@ -156,12 +156,89 @@ Expected string length {0} but was {1}. - - Expected any value except:<{1}>. Actual:<{2}>. {0} + + Expected values to be structurally equivalent. + + + Expected values to be structurally equivalent (strict mode). + + + reading the actual dictionary threw {0}: {1}. + + + enumerating the actual collection threw {0}: {1}. + + + reading the actual member threw {0}: {1}. + + + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + + + enumerating the expected collection threw {0}: {1}. + + + reading the expected member threw {0}: {1}. + + + actual has unexpected members not present on expected: {0}. + + + IEquatable.Equals threw {0}: {1}. + + + collections differ in length (expected {0} elements, actual {1}). + + + comparison exceeded the maximum supported depth of {0}. + + + key {0} present on expected is missing from actual. + + + member '{0}' present on expected is missing from actual. + + + one side is null, the other is not. + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + incompatible types (expected '{0}', actual '{1}'). + + + key {0} present on actual is not on expected. + + + values are not equal. + + + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. Expected a difference greater than <{3}> between expected value <{1}> and actual value <{2}>. {0} + + Expected any value except:<{1}>. Actual:<{2}>. {0} + + + Could not complete structural comparison. + + + Could not complete structural comparison (strict mode). + + + Expected values to be structurally different. + + + Expected values to be structurally different (strict mode). + Expected is <null>. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.cs.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.cs.xlf index 4ad5deac23..774cb58c1d 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.cs.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.cs.xlf @@ -68,6 +68,111 @@ Očekávaná délka řetězce je {0}, ale byla {1}. + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} Nebyla očekávána žádná hodnota kromě:<{1}>. Aktuálně:<{2}>. {0} @@ -78,6 +183,26 @@ Očekáván rozdíl, který je větší jak <{3}> mezi očekávanou hodnotou <{1}> a aktuální hodnotou <{2}>. {0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} Obě hodnoty jsou <null>. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.de.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.de.xlf index f61ee46c58..a18fe38b57 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.de.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.de.xlf @@ -68,6 +68,111 @@ Die erwartete Länge der Zeichenfolge ist {0}, war aber {1}. + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} Es wurde ein beliebiger Wert erwartet außer:<{1}>. Tatsächlich:<{2}>. {0} @@ -78,6 +183,26 @@ Es wurde eine Differenz größer als <{3}> zwischen dem erwarteten Wert <{1}> und dem tatsächlichen Wert <{2}> erwartet. {0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} Beide Werte sind <null>. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.es.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.es.xlf index 94a5797fc0..eff13018e8 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.es.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.es.xlf @@ -68,6 +68,111 @@ Se esperaba una longitud de cadena {0} pero fue {1}. + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} Se esperaba cualquier valor excepto <{1}>, pero es <{2}>. {0} @@ -78,6 +183,26 @@ Se esperaba una diferencia mayor que <{3}> entre el valor esperado <{1}> y el valor actual <{2}>. {0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} Ambos valores son <null>. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.fr.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.fr.xlf index 00d78302f3..e5b9365813 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.fr.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.fr.xlf @@ -68,6 +68,111 @@ La longueur de chaîne attendue {0} mais était {1}. + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} Toute valeur attendue sauf :<{1}>. Réel :<{2}>. {0} @@ -78,6 +183,26 @@ Différence attendue supérieure à <{3}> comprise entre la valeur attendue <{1}> et la valeur réelle <{2}>. {0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} Les deux valeurs sont <null>. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.it.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.it.xlf index 80b0b1b715..832126a4fc 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.it.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.it.xlf @@ -68,6 +68,111 @@ La lunghezza della stringa prevista è {0} ma era {1}. + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} Previsto qualsiasi valore tranne:<{1}>. Effettivo:<{2}>. {0} @@ -78,6 +183,26 @@ Prevista una differenza maggiore di <{3}> tra il valore previsto <{1}> e il valore effettivo <{2}>. {0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} Entrambi i valori sono <null>. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ja.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ja.xlf index ee5344f86b..d77f677844 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ja.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ja.xlf @@ -68,6 +68,111 @@ 期待される文字列の長さは {0} ですが、実際は {1} でした。 + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} <{1}> 以外の任意の値が必要ですが、<{2}> が指定されています。{0} @@ -78,6 +183,26 @@ 指定する値 <{1}> と実際の値 <{2}> との間には、<{3}> を超える差が必要です。{0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} どちらの値も<null>です。{0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ko.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ko.xlf index e1d64ff123..3c8a5436ca 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ko.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ko.xlf @@ -68,6 +68,111 @@ 문자열 길이 {0}(을)를 예상했지만 {1}입니다. + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} 예상 값: <{1}>을(를) 제외한 모든 값. 실제 값: <{2}>. {0} @@ -78,6 +183,26 @@ 예상 값 <{1}>과(와) 실제 값 <{2}>의 차이가 <{3}>보다 커야 합니다. {0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} 두 값 모두 <null>입니다. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pl.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pl.xlf index b09f5eb893..6c85ca160a 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pl.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pl.xlf @@ -68,6 +68,111 @@ Oczekiwano ciągu o długości {0}, ale miał wartość {1}. + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} Oczekiwano dowolnej wartości za wyjątkiem:<{1}>. Rzeczywista:<{2}>. {0} @@ -78,6 +183,26 @@ Oczekiwano różnicy większej niż <{3}> pomiędzy oczekiwaną wartością <{1}> a rzeczywistą wartością <{2}>. {0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} Obie wartości to <null>. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pt-BR.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pt-BR.xlf index 3fdabfe37d..a21237aea3 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pt-BR.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pt-BR.xlf @@ -68,6 +68,111 @@ Comprimento esperado da cadeia de caracteres {0}, mas foi {1}. + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} Esperado qualquer valor exceto:<{1}>. Real:<{2}>. {0} @@ -78,6 +183,26 @@ Esperada uma diferença maior que <{3}> entre o valor esperado <{1}> e o valor real <{2}>. {0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} Ambos os valores são <null>. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ru.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ru.xlf index 8262985ee7..fef2349561 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ru.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ru.xlf @@ -68,6 +68,111 @@ Ожидалась длина строки: {0}, фактическая длина строки: {1}. + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} Ожидается любое значение, кроме: <{1}>. Фактически: <{2}>. {0} @@ -78,6 +183,26 @@ Между ожидаемым значением <{1}> и фактическим значением <{2}> требуется разница более чем <{3}>. {0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} Оба значения равны <null>. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.tr.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.tr.xlf index 8420e144a3..79e1d5bbcd 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.tr.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.tr.xlf @@ -68,6 +68,111 @@ Beklenen dize uzunluğu {0} idi, ancak dize uzunluğu {1} oldu. + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} Şunun dışında bir değer bekleniyor:<{1}>. Gerçek:<{2}>. {0} @@ -78,6 +183,26 @@ Beklenen değer <{1}> ile gerçek değer <{2}> arasında, şundan büyük olan fark bekleniyor: <{3}>. {0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} Her iki değer: <null>. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hans.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hans.xlf index 0b7558f104..917f8a8f31 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hans.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hans.xlf @@ -68,6 +68,111 @@ 字符串长度应为 {0},但为 {1}。 + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} 应为: <{1}> 以外的任意值,实际为: <{2}>。{0} @@ -78,6 +183,26 @@ 预期值 <{1}> 和实际值 <{2}> 之间的差应大于 <{3}>。{0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} 两个值均为 <null>。{0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hant.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hant.xlf index cc658d8057..59865404f8 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hant.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hant.xlf @@ -68,6 +68,111 @@ 預期的字串長度為 {0},但為 {1}。 + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} 預期任何值 (<{1}> 除外)。實際: <{2}>。{0} @@ -78,6 +183,26 @@ 預期值 <{1}> 和實際值 <{2}> 之間的預期差異大於 <{3}>。{0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} 兩個值均為 <null>。{0} diff --git a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs new file mode 100644 index 0000000000..ed863b3464 --- /dev/null +++ b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs @@ -0,0 +1,1129 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using AwesomeAssertions; + +using TestFramework.ForTestingMSTest; + +namespace Microsoft.VisualStudio.TestPlatform.TestFramework.UnitTests; + +public partial class AssertTests : TestContainer +{ + #region AreEquivalent — primitives, nulls, strings + + public void AreEquivalent_BothNull_Passes() + => Assert.AreEquivalent(null, null); + + public void AreEquivalent_SameReference_Passes() + { + var o = new Person("a", 1); + Assert.AreEquivalent(o, o); + } + + public void AreEquivalent_ExpectedNull_ActualNotNull_Fails() + { + Action act = () => Assert.AreEquivalent(null, new object()); + act.Should().Throw() + .WithMessage("*structurally equivalent*one side is null*"); + } + + public void AreEquivalent_ExpectedNotNull_ActualNull_Fails() + { + Action act = () => Assert.AreEquivalent(new object(), null); + act.Should().Throw() + .WithMessage("*one side is null*"); + } + + public void AreEquivalent_EqualPrimitives_Passes() + { + Assert.AreEquivalent(42, 42); + Assert.AreEquivalent("abc", "abc"); + Assert.AreEquivalent(3.14, 3.14); + Assert.AreEquivalent(true, true); + Assert.AreEquivalent('z', 'z'); + } + + public void AreEquivalent_BoxedTypeValues_UseTypeEquality() + { + object expected = typeof(string); + object actual = typeof(string); + Assert.AreEquivalent(expected, actual); + } + + public void AreEquivalent_BoxedPrimitiveLikeAndReferenceType_FailsWithTypeMismatch() + { + object expected = 42; + object actual = new Person("Ada", 36); + Action act = () => Assert.AreEquivalent(expected, actual); + act.Should().Throw() + .And.Message.Should().Contain("incompatible types"); + } + + public void AreEquivalent_DifferentInts_Fails() + { + Action act = () => Assert.AreEquivalent(1, 2); + AssertFailedException ex = act.Should().Throw().Which; + ex.Message.Should().Contain("Mismatch at ''"); + ex.Message.Should().Contain("values are not equal"); + ex.ExpectedText.Should().Be("1"); + ex.ActualText.Should().Be("2"); + } + + public void AreEquivalent_Strings_DifferentValues_Fails() + { + Action act = () => Assert.AreEquivalent("foo", "bar"); + AssertFailedException ex = act.Should().Throw().Which; + ex.Message.Should().Contain("values are not equal"); + ex.ExpectedText.Should().Be("\"foo\""); + ex.ActualText.Should().Be("\"bar\""); + } + + public void AreEquivalent_Enums_Equal_Passes() + => Assert.AreEquivalent(DayOfWeek.Tuesday, DayOfWeek.Tuesday); + + public void AreEquivalent_Enums_Different_Fails() + { + Action act = () => Assert.AreEquivalent(DayOfWeek.Monday, DayOfWeek.Tuesday); + act.Should().Throw(); + } + + public void AreEquivalent_DateTime_Equal_Passes() + => Assert.AreEquivalent(new DateTime(2024, 1, 2, 3, 4, 5, DateTimeKind.Utc), new DateTime(2024, 1, 2, 3, 4, 5, DateTimeKind.Utc)); + + public void AreEquivalent_Guid_Different_Fails() + { + Action act = () => Assert.AreEquivalent(Guid.Parse("11111111-1111-1111-1111-111111111111"), Guid.Parse("22222222-2222-2222-2222-222222222222")); + act.Should().Throw(); + } + + #endregion + + #region AreEquivalent — POCOs + + public void AreEquivalent_IdenticalPocos_Passes() + => Assert.AreEquivalent(new Person("Ada", 36), new Person("Ada", 36)); + + public void AreEquivalent_PocoWithDifferentField_Fails() + { + Action act = () => Assert.AreEquivalent(new Person("Ada", 36), new Person("Ada", 37)); + AssertFailedException ex = act.Should().Throw().Which; + ex.Message.Should().Contain("Mismatch at 'Age'"); + ex.ExpectedText.Should().Be("36"); + ex.ActualText.Should().Be("37"); + } + + public void AreEquivalent_NestedPoco_DeepDifference_ReportedWithDottedPath() + { + Order expected = new("o-1", new Address("street1", "city1")); + Order actual = new("o-1", new Address("street1", "different-city")); + Action act = () => Assert.AreEquivalent(expected, actual); + AssertFailedException ex = act.Should().Throw().Which; + ex.Message.Should().Contain("Mismatch at 'ShippingAddress.City'"); + ex.ExpectedText.Should().Be("\"city1\""); + ex.ActualText.Should().Be("\"different-city\""); + } + + public void AreEquivalent_PublicFieldsAreCompared() + { + WithPublicField a = new() { Number = 1 }; + WithPublicField b = new() { Number = 2 }; + Action act = () => Assert.AreEquivalent(a, b); + act.Should().Throw() + .WithMessage("*Mismatch at 'Number'*"); + } + + public void AreEquivalent_AnonymousTypes_SameShape_Passes() + => Assert.AreEquivalent(new { Name = "Ada", Age = 36 }, new { Name = "Ada", Age = 36 }); + + public void AreEquivalent_PrivateFieldsAreIgnored() + { + WithPrivateField a = new(privateValue: "secret-1", publicValue: 1); + WithPrivateField b = new(privateValue: "secret-2", publicValue: 1); + Assert.AreEquivalent(a, b); + } + + #endregion + + #region AreEquivalent — collections + + public void AreEquivalent_EqualLists_Passes() + => Assert.AreEquivalent>([1, 2, 3], [1, 2, 3]); + + public void AreEquivalent_Lists_DifferentLengths_Fails() + { + Action act = () => Assert.AreEquivalent>([1, 2, 3], [1, 2]); + act.Should().Throw() + .WithMessage("*Mismatch at ''*collections differ in length*expected 3*actual 2*"); + } + + public void AreEquivalent_Lists_DifferentElement_ReportsIndex() + { + Action act = () => Assert.AreEquivalent([10, 20, 30], [10, 25, 30]); + AssertFailedException ex = act.Should().Throw().Which; + ex.Message.Should().Contain("Mismatch at '[1]'"); + ex.ExpectedText.Should().Be("20"); + ex.ActualText.Should().Be("25"); + } + + public void AreEquivalent_OrderSensitive_Fails() + { + Action act = () => Assert.AreEquivalent([1, 2], [2, 1]); + act.Should().Throw() + .WithMessage("*Mismatch at '[0]'*"); + } + + public void AreEquivalent_NestedCollectionInsidePoco() + { + Order expected = new("o", new Address("s", "c")) { Items = { 1, 2, 3 } }; + Order actual = new("o", new Address("s", "c")) { Items = { 1, 999, 3 } }; + Action act = () => Assert.AreEquivalent(expected, actual); + AssertFailedException ex = act.Should().Throw().Which; + ex.Message.Should().Contain("Mismatch at 'Items[1]'"); + ex.ExpectedText.Should().Be("2"); + ex.ActualText.Should().Be("999"); + } + + #endregion + + #region AreEquivalent — dictionaries + + public void AreEquivalent_EqualDictionaries_Passes() + { + var a = new Dictionary { ["a"] = 1, ["b"] = 2 }; + var b = new Dictionary { ["b"] = 2, ["a"] = 1 }; + Assert.AreEquivalent(a, b); + } + + public void AreEquivalent_Dictionary_MissingKey_Fails() + { + var a = new Dictionary { ["a"] = 1, ["b"] = 2 }; + var b = new Dictionary { ["a"] = 1 }; + Action act = () => Assert.AreEquivalent(a, b); + act.Should().Throw() + .And.Message.Should().Contain("key \"b\"").And.Contain("missing from actual"); + } + + public void AreEquivalent_Dictionary_DifferentValue_Fails() + { + var a = new Dictionary { ["a"] = 1 }; + var b = new Dictionary { ["a"] = 2 }; + Action act = () => Assert.AreEquivalent(a, b); + AssertFailedException ex = act.Should().Throw().Which; + ex.Message.Should().Contain("Mismatch at '[\"a\"]'"); + ex.ExpectedText.Should().Be("1"); + ex.ActualText.Should().Be("2"); + } + + public void AreEquivalent_Dictionary_ExtraKeyOnActual_NonStrict_Passes() + { + // Non-strict mode (default): extra keys on actual are tolerated. + var a = new Dictionary { ["a"] = 1 }; + var b = new Dictionary { ["a"] = 1, ["b"] = 2 }; + Assert.AreEquivalent(a, b); + } + + public void AreEquivalent_Dictionary_ExtraKeyOnActual_Strict_Fails() + { + var a = new Dictionary { ["a"] = 1 }; + var b = new Dictionary { ["a"] = 1, ["b"] = 2 }; + Action act = () => Assert.AreEquivalent(a, b, strict: true); + act.Should().Throw() + .And.Message.Should().Contain("key \"b\"").And.Contain("not on expected"); + } + + public void AreEquivalent_Dictionary_NonStringKey_Equal_Passes() + { + var a = new Dictionary { [1] = "one", [2] = "two" }; + var b = new Dictionary { [2] = "two", [1] = "one" }; + Assert.AreEquivalent(a, b); + } + + public void AreEquivalent_Dictionary_NonStringKey_DifferentValue_FailsWithKeyInPath() + { + var a = new Dictionary { [42] = "x" }; + var b = new Dictionary { [42] = "y" }; + Action act = () => Assert.AreEquivalent(a, b); + act.Should().Throw() + .And.Message.Should().Contain("Mismatch at '[42]'"); + } + + public void AreEquivalent_Dictionary_KeyPath_UsesRenderedValue() + { + string key = "line1\nline2"; + var a = new Dictionary { [key] = 1 }; + var b = new Dictionary { [key] = 2 }; + Action act = () => Assert.AreEquivalent(a, b); + act.Should().Throw() + .And.Message.Should().Contain("Mismatch at '[\"line1\\nline2\"]'"); + } + + public void AreEquivalent_Dictionary_CaseInsensitiveComparer_RespectsSourceComparer() + { + // The source dictionaries use OrdinalIgnoreCase. The comparer must defer to the source's + // own key lookup so "Hello" == "hello" is honored. + var a = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Hello"] = 1 }; + var b = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["hello"] = 1 }; + Assert.AreEquivalent(a, b); + } + + public void AreEquivalent_ReadOnlyDictionaryOnlyType_TreatedAsDictionary() + { + ReadOnlyDictionaryOnly a = new() { ["a"] = 1, ["b"] = 2 }; + ReadOnlyDictionaryOnly b = new() { ["b"] = 2, ["a"] = 1 }; + // Both implement only IReadOnlyDictionary<,> (not IDictionary<,>); should be detected as a + // dictionary and compared by key set, not as an ordered KeyValuePair sequence. + Assert.AreEquivalent(a, b); + } + + public void AreEquivalent_MixedDictionaryAndNonDictionary_TypeMismatch() + { + var dict = new Dictionary { ["a"] = 1 }; + var list = new List> { new("a", 1) }; + Action act = () => Assert.AreEquivalent(dict, list); + act.Should().Throw() + .And.Message.Should().Contain("incompatible types"); + } + + public void AreEquivalent_MemberGetterThrows_FailsWithExpectedDiagnostic() + { + ThrowingGetter a = new(); + ThrowingGetter b = new(); + Action act = () => Assert.AreEquivalent(a, b); + act.Should().Throw() + .And.Message.Should().Contain("Mismatch at 'BadProperty'").And.Contain("InvalidOperationException"); + } + + public void AreEquivalent_MemberGetterCallsAssertFail_PropagatesAsAssertFailedException() + { + // A property getter that calls Assert.Fail() throws AssertFailedException wrapped in a + // TargetInvocationException by Reflection. The equivalence comparer must NOT rewrite the + // framework exception as a structured "comparison failure" — it must propagate so the user's + // assertion surfaces with its original message AND original stack trace. + AssertFailingGetter a = new(); + AssertFailingGetter b = new(); + Action act = () => Assert.AreEquivalent(a, b); + AssertFailedException ex = act.Should().Throw() + .WithMessage("*nested-assert-fail*") + .Which; + ex.Message.Should().NotContain("Mismatch at"); + // The stack trace must point back to the user property getter (via ExceptionDispatchInfo); + // a naive `throw assertEx;` rethrow would replace this frame with the rethrow site. + ex.StackTrace.Should().NotBeNullOrEmpty().And.Contain(nameof(AssertFailingGetter)); + } + + public void AreEquivalent_IEquatableThrows_FailsWithExpectedDiagnostic() + { + ThrowingEquatable a = new(); + ThrowingEquatable b = new(); + Action act = () => Assert.AreEquivalent(a, b); + act.Should().Throw() + .And.Message.Should().Contain("IEquatable").And.Contain("InvalidOperationException"); + } + + public void AreEquivalent_Strict_MultipleExtraMembers_RenderedSortedAndCommaSeparated() + { + Person expected = new("Ada", 36); + PersonWithMultipleExtras actual = new("Ada", 36, "ada@example.com", "+44"); + Action act = () => Assert.AreEquivalent(expected, actual, strict: true); + AssertFailedException ex = act.Should().Throw().Which; + // Extras (Email, Phone) are sorted alphabetically by Ordinal comparer, comma-separated. + ex.Message.Should().Contain("Email, Phone"); + } + + public void AreEquivalent_TopologyMismatch_SharedSubobjectVsDistinctCopies() + { + // expected: A → shared → both A.Other and A.OtherAlso point to the *same* node. + SharedHolder shared = new() { Value = "x" }; + TopologyHolder expectedRoot = new() { Other = shared, OtherAlso = shared }; + // actual: two distinct nodes that compare equal field-by-field. + TopologyHolder actualRoot = new() { Other = new() { Value = "x" }, OtherAlso = new() { Value = "x" } }; + + Action act = () => Assert.AreEquivalent(expectedRoot, actualRoot); + act.Should().Throw() + .And.Message.Should().Contain("graph topology differs"); + } + + public void AreEquivalent_ValueTypeFieldsInSharedAndDistinctPositions_NoFalseTopologyFailure() + { + // Value types skip topology tracking, so an int (or struct) appearing in two field positions + // on expected vs two distinct copies on actual must still compare as equivalent. + TwoIntsHolder expected = new() { First = 7, Second = 7 }; + TwoIntsHolder actual = new() { First = 7, Second = 7 }; + Assert.AreEquivalent(expected, actual); + } + + public void AreEquivalent_ValueTypeIEquatableThrows_FailsWithExpectedDiagnostic() + { + // Boxing a struct that throws from IEquatable must still be caught and surfaced as a + // structured failure, not propagated. + ThrowingEquatableStruct a = default; + ThrowingEquatableStruct b = default; + Action act = () => Assert.AreEquivalent(a, b); + act.Should().Throw() + .And.Message.Should().Contain("IEquatable").And.Contain("InvalidOperationException"); + } + + public void AreEquivalent_OnlyActualGetterThrows_FailsWithActualSideDiagnostic() + { + // Only the actual side throws (expected uses the safe base type). Exercises the + // isExpected: false branch of MemberAccessFailure. + SafeGetter expected = new() { Value = 1 }; + ActualThrowingGetter actual = new(); + Action act = () => Assert.AreEquivalent(expected, actual); + act.Should().Throw() + .And.Message.Should().Contain("Mismatch at 'Value'").And.Contain("reading the actual member threw"); + } + + public void AreEquivalent_DictionaryThrowsFromTryGetValue_FailsWithExpectedDiagnostic() + { + var a = new Dictionary { ["a"] = 1 }; + ThrowingDictionary actual = new(); + Action act = () => Assert.AreEquivalent>(a, actual); + act.Should().Throw() + .And.Message.Should().Contain("reading the actual dictionary threw").And.Contain("InvalidOperationException"); + } + + public void AreEquivalent_EnumerableThrows_FailsWithExpectedDiagnostic() + { + IEnumerable expected = [1]; + IEnumerable actual = new ThrowingEnumerable(); + Action act = () => Assert.AreEquivalent(expected, actual); + act.Should().Throw() + .And.Message.Should().Contain("enumerating the actual collection threw").And.Contain("InvalidOperationException"); + } + + public void AreEquivalent_EnumerableMismatch_FailsFastWithoutEnumeratingPastMismatch() + { + IEnumerable expected = [1, 2, 3]; + IEnumerable actual = new FailIfEnumeratedPastIndexEnumerable(1, 1, 999, 3); + Action act = () => Assert.AreEquivalent(expected, actual); + act.Should().Throw() + .And.Message.Should().Contain("Mismatch at '[1]'").And.NotContain("enumerating the actual collection threw"); + } + + public void AreEquivalent_ReadOnlyDictionaryOnly_RespectsSourceComparer() + { + // Custom IReadOnlyDictionary<,>-only type backed by a case-insensitive Dictionary should + // honor the source's comparer through GenericDictionaryAccessors.TryGetValue. + var a = ReadOnlyDictionaryOnly.CaseInsensitive(); + var b = ReadOnlyDictionaryOnly.CaseInsensitive(); + a["Hello"] = 1; + b["hello"] = 1; + Assert.AreEquivalent(a, b); + } + + #endregion + + #region AreNotEquivalent — broader mirroring + + public void AreNotEquivalent_DifferentLists_Passes() + => Assert.AreNotEquivalent([1, 2, 3], [1, 2, 4]); + + public void AreNotEquivalent_EqualLists_Fails() + { + Action act = () => Assert.AreNotEquivalent([1, 2, 3], [1, 2, 3]); + act.Should().Throw(); + } + + public void AreNotEquivalent_DifferentDictionaries_Passes() + { + var a = new Dictionary { ["a"] = 1 }; + var b = new Dictionary { ["a"] = 2 }; + Assert.AreNotEquivalent(a, b); + } + + public void AreNotEquivalent_EqualDictionaries_Fails() + { + var a = new Dictionary { ["a"] = 1 }; + var b = new Dictionary { ["a"] = 1 }; + Action act = () => Assert.AreNotEquivalent(a, b); + act.Should().Throw(); + } + + public void AreNotEquivalent_TopologicallyIdenticalCycles_Fails() + { + Node a = new("v"); + a.Next = a; + Node b = new("v"); + b.Next = b; + Action act = () => Assert.AreNotEquivalent(a, b); + act.Should().Throw(); + } + + public void AreNotEquivalent_IEquatableSemantics_Fails() + { + EquatableMoney a = new(100m, "USD"); + EquatableMoney b = new(100m, "EUR"); + Action act = () => Assert.AreNotEquivalent(a, b); + act.Should().Throw(); + } + + public void AreNotEquivalent_IEquatableThrows_FailsWithComparisonFailure() + { + ThrowingEquatable a = new(); + ThrowingEquatable b = new(); + Action act = () => Assert.AreNotEquivalent(a, b); + act.Should().Throw() + .And.Message.Should().Contain("Could not complete structural comparison").And.Contain("IEquatable").And.Contain("InvalidOperationException"); + } + + public void AreNotEquivalent_CallSiteExpression_IsRendered() + { + int x = 1; + int y = 1; + Action act = () => Assert.AreNotEquivalent(x, y); + act.Should().Throw() + .And.Message.Should().Contain("Assert.AreNotEquivalent(x, y)"); + } + + #endregion + + #region AreEquivalent — IEquatable shortcut + + public void AreEquivalent_IEquatableType_UsesEquals_NotMembers() + { + // EquatableMoney has IEquatable that ignores Currency. + // So 100 USD vs 100 EUR are considered equivalent by IEquatable. + EquatableMoney a = new(100m, "USD"); + EquatableMoney b = new(100m, "EUR"); + Assert.AreEquivalent(a, b); + } + + public void AreEquivalent_IEquatableType_DifferentValues_Fails() + { + EquatableMoney a = new(100m, "USD"); + EquatableMoney b = new(200m, "USD"); + Action act = () => Assert.AreEquivalent(a, b); + AssertFailedException ex = act.Should().Throw().Which; + // IEquatableOutcome.NotEqual must surface the rendered values via the structured failure. + ex.ExpectedText.Should().NotBeNull(); + ex.ActualText.Should().NotBeNull(); + } + + public void AreEquivalent_PlainEqualsOverride_DoesNotShortCircuit_RecursesIntoMembers() + { + // OnlyEqualsOverride.Equals always returns true, but we should ignore that override + // and recurse into the Value property, which differs. + OnlyEqualsOverride a = new(1); + OnlyEqualsOverride b = new(2); + Action act = () => Assert.AreEquivalent(a, b); + act.Should().Throw() + .WithMessage("*Mismatch at 'Value'*"); + } + + #endregion + + #region AreEquivalent — cycles + + public void AreEquivalent_Cycles_DoNotStackOverflow() + { + Node a = new("x"); + a.Next = a; + Node b = new("x"); + b.Next = b; + Assert.AreEquivalent(a, b); + } + + public void AreEquivalent_CrossCycles_Detected_Pass() + { + Node a = new("v"); + Node b = new("v"); + a.Next = b; + b.Next = a; + Assert.AreEquivalent(a, b); + } + + public void AreEquivalent_DeepGraph_AtMaximumSupportedDepth_Passes() + { + DeepNode expected = CreateDeepNodeChain(256); + DeepNode actual = CreateDeepNodeChain(256); + Assert.AreEquivalent(expected, actual); + } + + public void AreEquivalent_DeepGraph_ReportsMaxDepthExceeded() + { + DeepNode expected = CreateDeepNodeChain(257); + DeepNode actual = CreateDeepNodeChain(257); + Action act = () => Assert.AreEquivalent(expected, actual); + act.Should().Throw() + .And.Message.Should().Contain("maximum supported depth of 256"); + } + + #endregion + + #region AreEquivalent — strict mode + + public void AreEquivalent_Strict_DifferentRuntimeTypes_ExtraMemberOnActual_Fails() + { + Person expected = new("Ada", 36); + PersonWithEmail actual = new("Ada", 36, "ada@example.com"); + // Non-strict: passes (Email is ignored). + Assert.AreEquivalent(expected, actual); + + // Strict: fails because actual has extra member 'Email'. + Action act = () => Assert.AreEquivalent(expected, actual, strict: true); + act.Should().Throw() + .WithMessage("*structurally equivalent (strict mode)*Email*"); + } + + public void AreEquivalent_Strict_SameRuntimeType_NotAffected() + => Assert.AreEquivalent(new Person("Ada", 36), new Person("Ada", 36), strict: true); + + public void AreEquivalent_Strict_Dictionary_ExtraKey_Fails() + { + // Strict: still fails the same way. + var a = new Dictionary { ["a"] = 1 }; + var b = new Dictionary { ["a"] = 1, ["b"] = 2 }; + Action act = () => Assert.AreEquivalent(a, b, strict: true); + act.Should().Throw(); + } + + public void AreEquivalent_IEquatableElementInList_UsesIEquatable() + { + // List recursion preserves the declared element type, so the IEquatable + // shortcut applies per element. EUR vs USD differ via members but are equal via IEquatable. + var a = new List { new(100m, "USD") }; + var b = new List { new(100m, "EUR") }; + Assert.AreEquivalent(a, b); + } + + public void AreEquivalent_IEquatableElementInArray_UsesIEquatable() + { + EquatableMoney[] a = [new(50m, "USD")]; + EquatableMoney[] b = [new(50m, "EUR")]; + Assert.AreEquivalent(a, b); + } + + public void AreEquivalent_IEquatableValueInDictionary_UsesIEquatable() + { + var a = new Dictionary { ["price"] = new(10m, "USD") }; + var b = new Dictionary { ["price"] = new(10m, "EUR") }; + Assert.AreEquivalent(a, b); + } + + public void AreEquivalent_ExplicitIEquatableImpl_Honored() + { + // Even though the Equals(T) is implemented explicitly (not as a public concrete method), + // reflection-driven interface-method dispatch still finds it. + ExplicitEquatable a = new(1); + ExplicitEquatable b = new(2); + Assert.AreEquivalent(a, b); + } + + public void AreEquivalent_TopologyMismatch_Detected() + { + // expected: a -> b -> a (two distinct nodes in a 2-cycle) + Node a = new("x"); + Node b = new("x"); + a.Next = b; + b.Next = a; + + // actual: c -> c (single self-cycle) + Node c = new("x"); + c.Next = c; + + Action act = () => Assert.AreEquivalent(a, c); + act.Should().Throw() + .And.Message.Should().Contain("graph topology differs"); + } + + public void AreEquivalent_SelfCycleThroughField() + { + NodeWithField a = new() { Label = "x" }; + a.Next = a; + NodeWithField b = new() { Label = "x" }; + b.Next = b; + Assert.AreEquivalent(a, b); + } + + #endregion + + #region AreEquivalent — user message and call-site expression + + public void AreEquivalent_UserMessage_IsIncluded() + { + Action act = () => Assert.AreEquivalent(1, 2, "boom"); + act.Should().Throw() + .And.Message.Should().Contain("boom"); + } + + public void AreEquivalent_CallSiteExpression_IsRendered() + { + int x = 1; + int y = 2; + Action act = () => Assert.AreEquivalent(x, y); + act.Should().Throw() + .And.Message.Should().Contain("Assert.AreEquivalent(x, y)"); + } + + public void AreEquivalent_FailureMessage_FollowsRfc012Layout() + { + int expected = 1; + int actual = 2; + Action act = () => Assert.AreEquivalent(expected, actual, "numbers should match"); + act.Should().Throw() + .WithMessage( + """ + Assertion failed. Expected values to be structurally equivalent. + Mismatch at '': values are not equal. + numbers should match + + expected: 1 + actual: 2 + + Assert.AreEquivalent(expected, actual) + """); + } + + #endregion + + #region AreNotEquivalent + + public void AreNotEquivalent_DifferentValues_Passes() + => Assert.AreNotEquivalent(new Person("Ada", 36), new Person("Ada", 37)); + + public void AreNotEquivalent_EqualValues_Fails() + { + Action act = () => Assert.AreNotEquivalent(new Person("Ada", 36), new Person("Ada", 36)); + act.Should().Throw() + .WithMessage("*structurally different*"); + } + + public void AreNotEquivalent_BothNull_Fails() + { + Action act = () => Assert.AreNotEquivalent(null, null); + act.Should().Throw(); + } + + public void AreNotEquivalent_PlainEqualsOverride_StillRecurses_PassesOnDifferentValue() + => Assert.AreNotEquivalent(new OnlyEqualsOverride(1), new OnlyEqualsOverride(2)); + + public void AreNotEquivalent_StrictMode_ExtraMemberOnActual_Passes() + { + Person expected = new("Ada", 36); + PersonWithEmail actual = new("Ada", 36, "ada@example.com"); + // Strict mode: extra member on actual makes them NOT equivalent → AreNotEquivalent passes. + Assert.AreNotEquivalent(expected, actual, strict: true); + } + + public void AreNotEquivalent_UserMessage_IsIncluded() + { + Action act = () => Assert.AreNotEquivalent(1, 1, "boom"); + act.Should().Throw() + .And.Message.Should().Contain("boom"); + } + + #endregion + + #region Test helpers + + private static DeepNode CreateDeepNodeChain(int length) + { + DeepNode root = new(); + DeepNode current = root; + for (int i = 1; i < length; i++) + { + current.Next = new DeepNode(); + current = current.Next!; + } + + return root; + } + + public void AreEquivalent_NewShadowedProperty_UsesMostDerivedDeclaration() + { + // The derived class shadows the base `int Value` with `string Value`. + // If the comparer ever picked the base accessor, it would compare base.Value (always 0 on both + // sides) and silently pass — masking the genuine derived-value mismatch. The expected outcome + // is a failure that mentions the derived string values. + var expected = new ShadowedDerived { Value = "derived-expected" }; + var actual = new ShadowedDerived { Value = "derived-actual" }; + + Action act = () => Assert.AreEquivalent(expected, actual); + act.Should().Throw() + .WithMessage("*Value*derived-expected*derived-actual*"); + } + + public void AreEquivalent_NewShadowedProperty_SameType_DetectsDerivedMismatch() + { + // Same-type `new` shadowing (int → int with different defaults). If the comparer picked the + // base accessor, both sides would resolve to the base default (100) and the mismatch on the + // derived value (200 vs 999) would be silently swallowed. The expected outcome is a failure + // that mentions the derived integers. + var expected = new SameTypeShadowDerived { Value = 200 }; + var actual = new SameTypeShadowDerived { Value = 999 }; + + Action act = () => Assert.AreEquivalent(expected, actual); + act.Should().Throw() + .WithMessage("*Value*200*999*"); + } + + private class ShadowedBase + { + public int Value { get; set; } + } + + private sealed class ShadowedDerived : ShadowedBase + { + public new string Value { get; set; } = "default"; + } + + private class SameTypeShadowBase + { + public int Value { get; set; } = 100; + } + + private sealed class SameTypeShadowDerived : SameTypeShadowBase + { + public new int Value { get; set; } = 200; + } + + private sealed class Person + { + public Person(string name, int age) + { + Name = name; + Age = age; + } + + public string Name { get; } + + public int Age { get; } + } + + private sealed class PersonWithEmail + { + public PersonWithEmail(string name, int age, string email) + { + Name = name; + Age = age; + Email = email; + } + + public string Name { get; } + + public int Age { get; } + + public string Email { get; } + } + + private sealed class Address + { + public Address(string street, string city) + { + Street = street; + City = city; + } + + public string Street { get; } + + public string City { get; } + } + + private sealed class Order + { + public Order(string id, Address shippingAddress) + { + Id = id; + ShippingAddress = shippingAddress; + } + + public string Id { get; } + + public Address ShippingAddress { get; } + + public List Items { get; } = []; + } + + private sealed class WithPublicField + { +#pragma warning disable SA1401 // Field should be private - intentional: this type tests public-field comparison. + public int Number; +#pragma warning restore SA1401 + } + + private sealed class WithPrivateField + { +#pragma warning disable IDE0052 // Remove unread private members - intentional for the test + private readonly string _privateValue; +#pragma warning restore IDE0052 + + public WithPrivateField(string privateValue, int publicValue) + { + _privateValue = privateValue; + PublicValue = publicValue; + } + + public int PublicValue { get; } + } + + private sealed class EquatableMoney : IEquatable + { + public EquatableMoney(decimal amount, string currency) + { + Amount = amount; + Currency = currency; + } + + public decimal Amount { get; } + + public string Currency { get; } + + // Intentionally ignores Currency to demonstrate that IEquatable.Equals takes precedence. + public bool Equals(EquatableMoney? other) => other is not null && other.Amount == Amount; + + public override bool Equals(object? obj) => Equals(obj as EquatableMoney); + + public override int GetHashCode() => Amount.GetHashCode(); + } + + private sealed class OnlyEqualsOverride + { + public OnlyEqualsOverride(int value) => Value = value; + + public int Value { get; } + + public override bool Equals(object? obj) => true; + + public override int GetHashCode() => 0; + } + + private sealed class Node + { + public Node(string label) => Label = label; + + public string Label { get; } + + public Node? Next { get; set; } + } + + private sealed class DeepNode + { + public DeepNode? Next { get; set; } + } + + private sealed class NodeWithField + { +#pragma warning disable SA1401 // Field should be private - intentional: this type tests public-field cycle handling. + public string? Label; + public NodeWithField? Next; +#pragma warning restore SA1401 + } + + private sealed class ExplicitEquatable : IEquatable + { +#pragma warning disable IDE0052 // Remove unread private members - intentional: ensures the type is non-trivial without exposing public members. + private readonly int _value; +#pragma warning restore IDE0052 + + public ExplicitEquatable(int value) => _value = value; + + // Explicit interface implementation: Equals always returns true so we can detect that the + // comparer dispatched through IEquatable rather than recursing into _value via reflection. + bool IEquatable.Equals(ExplicitEquatable? other) => true; + } + + private sealed class PersonWithMultipleExtras + { + public PersonWithMultipleExtras(string name, int age, string email, string phone) + { + Name = name; + Age = age; + Email = email; + Phone = phone; + } + + public string Name { get; } + + public int Age { get; } + + // Out-of-order declarations to confirm extras are sorted alphabetically in the message. + public string Phone { get; } + + public string Email { get; } + } + + private sealed class ThrowingGetter + { + public string BadProperty => throw new InvalidOperationException("nope"); + } + + private sealed class AssertFailingGetter + { + public string Value + { + get + { + Assert.Fail("nested-assert-fail"); + return string.Empty; + } + } + } + + private sealed class ThrowingEquatable : IEquatable + { + public bool Equals(ThrowingEquatable? other) => throw new InvalidOperationException("equality boom"); + + public override bool Equals(object? obj) => Equals(obj as ThrowingEquatable); + + public override int GetHashCode() => 0; + } + + private sealed class SharedHolder + { + public string? Value { get; set; } + } + + private sealed class TopologyHolder + { + public SharedHolder? Other { get; set; } + + public SharedHolder? OtherAlso { get; set; } + } + + /// + /// Custom dictionary-shaped type that implements ONLY + /// (not nor ). + /// + private sealed class ReadOnlyDictionaryOnly : IReadOnlyDictionary + where TKey : notnull + { + private readonly Dictionary _inner; + + public ReadOnlyDictionaryOnly() => _inner = []; + +#pragma warning disable IDE0028 // Collection initialization can be simplified - target-typed `new` cannot pass the comparer. + private ReadOnlyDictionaryOnly(IEqualityComparer comparer) => _inner = new(comparer); +#pragma warning restore IDE0028 + + public TValue this[TKey key] + { + get => _inner[key]; + set => _inner[key] = value; + } + + public IEnumerable Keys => _inner.Keys; + + public IEnumerable Values => _inner.Values; + + public int Count => _inner.Count; + + internal static ReadOnlyDictionaryOnly CaseInsensitive() + => new ReadOnlyDictionaryOnly(StringComparer.OrdinalIgnoreCase); + + public bool ContainsKey(TKey key) => _inner.ContainsKey(key); + + public IEnumerator> GetEnumerator() => _inner.GetEnumerator(); + +#pragma warning disable CS8767 // Nullability of reference types in type of parameter doesn't match implicitly implemented member. - net48 / netstandard target ships TryGetValue without [MaybeNullWhen]; net9.0 does. The mismatch is harmless for the test. + public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) => _inner.TryGetValue(key, out value); +#pragma warning restore CS8767 + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + private sealed class TwoIntsHolder + { + public int First { get; set; } + + public int Second { get; set; } + } + + private readonly struct ThrowingEquatableStruct : IEquatable + { + public bool Equals(ThrowingEquatableStruct other) => throw new InvalidOperationException("struct equality boom"); + + public override bool Equals(object? obj) => obj is ThrowingEquatableStruct other && Equals(other); + + public override int GetHashCode() => 0; + } + + private class SafeGetter + { + public virtual int Value { get; set; } + } + + private sealed class ActualThrowingGetter : SafeGetter + { + public override int Value + { + get => throw new InvalidOperationException("actual side boom"); + set { } + } + } + + private sealed class ThrowingEnumerable : IEnumerable + { + public IEnumerator GetEnumerator() => throw new InvalidOperationException("enumeration boom"); + + IEnumerator IEnumerable.GetEnumerator() => throw new InvalidOperationException("enumeration boom"); + } + + private sealed class FailIfEnumeratedPastIndexEnumerable : IEnumerable + { + private readonly int[] _items; + private readonly int _maxIndex; + + public FailIfEnumeratedPastIndexEnumerable(int maxIndex, params int[] items) + { + _maxIndex = maxIndex; + _items = items; + } + + public IEnumerator GetEnumerator() + { + for (int i = 0; i < _items.Length; i++) + { + yield return i > _maxIndex + ? throw new InvalidOperationException("enumerated past mismatch") + : _items[i]; + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + private sealed class ThrowingDictionary : IDictionary + { + public int this[string key] + { + get => throw new InvalidOperationException("indexer boom"); + set => throw new InvalidOperationException("indexer boom"); + } + + public ICollection Keys => throw new InvalidOperationException("Keys boom"); + + public ICollection Values => throw new InvalidOperationException("Values boom"); + + public int Count => 0; + + public bool IsReadOnly => true; + + public void Add(string key, int value) => throw new NotSupportedException(); + + public void Add(KeyValuePair item) => throw new NotSupportedException(); + + public void Clear() => throw new NotSupportedException(); + + public bool Contains(KeyValuePair item) => throw new InvalidOperationException("Contains boom"); + + public bool ContainsKey(string key) => throw new InvalidOperationException("ContainsKey boom"); + + public void CopyTo(KeyValuePair[] array, int arrayIndex) => throw new NotSupportedException(); + + public IEnumerator> GetEnumerator() + { + yield break; + } + + public bool Remove(string key) => throw new NotSupportedException(); + + public bool Remove(KeyValuePair item) => throw new NotSupportedException(); + + public bool TryGetValue(string key, out int value) => throw new InvalidOperationException("TryGetValue boom"); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + #endregion +}