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}.
+
+
+
+
+ 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}.
+
+
+
+
+ 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}.
+
+
+
+
+ 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}.
+
+
+
+
+ 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}.
+
+
+
+
+ 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}.
+
+
+
+
+ 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}.
+
+
+
+
+ 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}.
+
+
+
+
+ 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}.
+
+
+
+
+ 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}.
+
+
+
+
+ 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}.
+
+
+
+
+ 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}.
+
+
+
+
+ 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}.
+
+
+
+
+ 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
+}