From 523f1fd9978bd531f428bee49681c32ba44e0575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Fri, 15 May 2026 18:08:51 +0200 Subject: [PATCH 1/8] Add Assert.AreEquivalent for deep structural comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `Assert.AreEquivalent` and `Assert.AreNotEquivalent` for structural (deep) equality checks of object graphs, addressing the long-standing request in #4776. Design follows xUnit's `Assert.Equivalent` more than FluentAssertions' `BeEquivalentTo`, in line with MSTest's preference for a small, focused API surface that can grow without breaking changes: - 8 new public overloads (4 each for `AreEquivalent` and `AreNotEquivalent`), with optional `bool strict` and standard `CallerArgumentExpression` plumbing. No options bag for now — it can be layered on later as a pure addition. - Comparison rules: - Reference-equal values (and both null) are equivalent. - "Primitive-like" types (numerics, string, bool, char, enums, DateTime, DateTimeOffset, TimeSpan, Guid, Uri, Type, Version, DateOnly/TimeOnly/Half/Int128/UInt128 by name) → `Equals`. - Hybrid `IEquatable` shortcut: when the *static* declared type implements `IEquatable`, dispatch via the interface method (so explicit interface implementations work too). Plain `object.Equals` overrides are ignored. - Dictionaries (`IDictionary`, `IDictionary<,>`, or `IReadOnlyDictionary<,>`) are compared by key set with values compared recursively. Lookups are routed through the source dictionary so its `IEqualityComparer` is preserved. - Other enumerables (excluding string) are compared element-by-element in iteration order (xUnit-compatible). Element type is taken from the declared `IEnumerable` so the `IEquatable` shortcut still applies for nested values. - Other reference types are compared by recursing into all public instance properties and public instance fields, sorted by name for stable diagnostics. - Reference cycles are tracked via persistent bidirectional ref-equality maps; the same mechanism enforces graph topology (the same expected reference can never be paired with two different actual references). - User-defined `IEquatable.Equals`, member getters, and dictionary accesses (`TryGetValue`, `ContainsKey`, enumeration) that throw are caught and surfaced as a structured failure. - `strict: true` additionally rejects extra public members on `actual` (when runtime types differ) and any extra dictionary keys on `actual`. Failure messages use the existing structured assertion infrastructure (RFC 012): a top-line summary, a `Mismatch at '': ` locator, and `expected:` / `actual:` evidence with the values rendered through `AssertionValueRenderer`. `AssertFailedException.ExpectedText` and `ActualText` are populated for any mismatch that can be rendered as two values. Implementation lives in two files: - `Assert.AreEquivalent.cs` — public surface and structured-message reporting helpers. - `Assert.AreEquivalent.Comparer.cs` — `EquivalenceComparer` (the recursive walker), `MemberAccessor`/`MemberLookup` caches keyed by runtime type, `DictionaryView`/`GenericDictionaryView` abstractions that defer lookups to the source dictionary, `EquivalenceMismatch` carrying path + reason + rendered values. 15 new strings added to `FrameworkMessages.resx`; sibling XLFs are regenerated via `dotnet msbuild /t:UpdateXlf`. Tests use the in-repo `TestFramework.ForTestingMSTest.TestContainer` with AwesomeAssertions. 62 tests cover primitives/null/strings/dates/ enums/Guid, identical and different POCOs (with dotted-path failure locators), public fields, anonymous types, ignored private fields, collections (length/element/order), nested collections, dictionaries (equal, missing key, different value, extra key non-strict vs strict, non-string keys, case-insensitive comparer respected, custom `IReadOnlyDictionary<,>`-only types), `IEquatable` (regular, explicit interface impl, value-type, throws), plain `Equals` override ignored, self-cycles and cross-cycles via property and field, topology mismatch detection, value-type topology skip, member-getter throws on expected and actual sides, dictionary-access throws, strict-mode extra member rendering (alphabetical), user message and call-site expression rendering, plus broad `AreNotEquivalent` mirroring. All tests pass on net9.0 and net48. Full TestFramework.UnitTests suite (954 tests) green. Builds clean under `/p:TreatWarningsAsErrors=true` across netstandard2.0, net462, net8.0, net9.0, and the WinUI test target. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Assert.AreEquivalent.Comparer.cs | 1004 +++++++++++++++++ .../Assertions/Assert.AreEquivalent.cs | 294 +++++ .../PublicAPI/PublicAPI.Unshipped.txt | 4 + .../Resources/FrameworkMessages.resx | 62 + .../Resources/xlf/FrameworkMessages.cs.xlf | 100 ++ .../Resources/xlf/FrameworkMessages.de.xlf | 100 ++ .../Resources/xlf/FrameworkMessages.es.xlf | 100 ++ .../Resources/xlf/FrameworkMessages.fr.xlf | 100 ++ .../Resources/xlf/FrameworkMessages.it.xlf | 100 ++ .../Resources/xlf/FrameworkMessages.ja.xlf | 100 ++ .../Resources/xlf/FrameworkMessages.ko.xlf | 100 ++ .../Resources/xlf/FrameworkMessages.pl.xlf | 100 ++ .../Resources/xlf/FrameworkMessages.pt-BR.xlf | 100 ++ .../Resources/xlf/FrameworkMessages.ru.xlf | 100 ++ .../Resources/xlf/FrameworkMessages.tr.xlf | 100 ++ .../xlf/FrameworkMessages.zh-Hans.xlf | 100 ++ .../xlf/FrameworkMessages.zh-Hant.xlf | 100 ++ .../AssertTests.AreEquivalentTests.cs | 914 +++++++++++++++ 18 files changed, 3578 insertions(+) create mode 100644 src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs create mode 100644 src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs create mode 100644 test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs 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..918f458230 --- /dev/null +++ b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs @@ -0,0 +1,1004 @@ +// 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 +{ + /// + /// Walks two object graphs and reports the first structural difference, if any. + /// + private sealed class EquivalenceComparer + { + // Member info caches keyed by runtime type. + 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); + + private EquivalenceMismatch? Compare(object? expected, object? actual, Type declaredType, string path) + { + if (ReferenceEquals(expected, actual)) + { + return null; + } + + if (expected is null || actual is null) + { + return EquivalenceMismatch.NullMismatch(path, expected, actual); + } + + Type expectedRuntimeType = expected.GetType(); + Type actualRuntimeType = actual.GetType(); + + // Primitive-ish types: trust Equals. + if (IsPrimitiveLike(expectedRuntimeType) || IsPrimitiveLike(actualRuntimeType)) + { + return Equals(expected, actual) + ? null + : EquivalenceMismatch.ValueMismatch(path, expected, actual); + } + + // 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. + 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); + } + + // 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); + } + + return CompareMembers(expected, actual, expectedRuntimeType, actualRuntimeType, path); + } + + private EquivalenceMismatch? CompareDictionaries(DictionaryView expected, DictionaryView actual, Type valueDeclaredType, string path) + { + 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); + }); + 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) + { + return EquivalenceMismatch.DictionaryAccessFailure(path, isExpected, tie.InnerException ?? tie); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + return EquivalenceMismatch.DictionaryAccessFailure(path, isExpected, ex); + } + + using (enumerator) + { + while (true) + { + KeyValuePair kvp; + try + { + if (!enumerator.MoveNext()) + { + return null; + } + + kvp = enumerator.Current; + } + catch (TargetInvocationException tie) + { + return EquivalenceMismatch.DictionaryAccessFailure(path, isExpected, tie.InnerException ?? tie); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + 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) + { + result = default!; + return EquivalenceMismatch.DictionaryAccessFailure(path, isExpected, tie.InnerException ?? tie); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + result = default!; + return EquivalenceMismatch.DictionaryAccessFailure(path, isExpected, ex); + } + } + + private EquivalenceMismatch? CompareEnumerables(IEnumerable expected, IEnumerable actual, Type elementDeclaredType, string path) + { + // Materialize once so we can report length and walk in parallel. + List expectedItems = ToList(expected); + List actualItems = ToList(actual); + + if (expectedItems.Count != actualItems.Count) + { + return EquivalenceMismatch.LengthMismatch(path, expectedItems.Count, actualItems.Count); + } + + for (int i = 0; i < expectedItems.Count; i++) + { + EquivalenceMismatch? nested = Compare(expectedItems[i], actualItems[i], elementDeclaredType, AppendIndex(path, i)); + if (nested is not null) + { + return nested; + } + } + + return null; + } + + private EquivalenceMismatch? CompareMembers(object expected, object actual, Type expectedType, Type actualType, string path) + { + 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 }) + { + extras.Sort(StringComparer.Ordinal); + 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) + { + return EquivalenceMismatch.MemberAccessFailure(childPath, isExpected: true, ex.InnerException ?? ex); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + return EquivalenceMismatch.MemberAccessFailure(childPath, isExpected: true, ex); + } + + try + { + actualValue = matchingActual.GetValue(actual); + } + catch (TargetInvocationException ex) + { + return EquivalenceMismatch.MemberAccessFailure(childPath, isExpected: false, ex.InnerException ?? ex); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + return EquivalenceMismatch.MemberAccessFailure(childPath, isExpected: false, ex); + } + + EquivalenceMismatch? nested = Compare(expectedValue, actualValue, member.MemberType, childPath); + if (nested is not null) + { + return nested; + } + } + + return null; + } + + private static List ToList(IEnumerable source) + { +#pragma warning disable IDE0028 // Collection initialization can be simplified - we want the capacity-aware ctor when ICollection is available. + List list = source is ICollection collection + ? new List(collection.Count) + : []; +#pragma warning restore IDE0028 + foreach (object? item in source) + { + list.Add(item); + } + + return list; + } + + 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) + { + thrown = ex.InnerException ?? ex; + return IEquatableOutcome.Threw; + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + 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 || t.IsPointer) + { + 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; + if (fullName is "System.DateOnly" or "System.TimeOnly" or "System.Half" or "System.Int128" or "System.UInt128") + { + return true; + } + + // Compare reflection types as primitive (they all derive from System.Type). + return typeof(Type).IsAssignableFrom(t); + }); + + private static bool TryCreateDictionaryView(object value, out DictionaryView? view) + { + 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 => + { + List list = []; + + 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; + } + + list.Add(new MemberAccessor(p.Name, p.PropertyType, p)); + } + + foreach (FieldInfo f in t.GetFields(BindingFlags.Public | BindingFlags.Instance)) + { + if (f.IsStatic) + { + continue; + } + + list.Add(new MemberAccessor(f.Name, f.FieldType, f)); + } + + // Sort alphabetically for stable diagnostics across runtimes. + list.Sort(static (a, b) => StringComparer.Ordinal.Compare(a.Name, b.Name)); + + MemberAccessor[] sorted = list.ToArray(); + Dictionary byName = new(sorted.Length, StringComparer.Ordinal); + foreach (MemberAccessor m in sorted) + { + // Property/field name shadowing is rare; keep the first one we encounter + // (alphabetical order is deterministic). + byName[m.Name] = m; + } + + return new MemberLookup(sorted, byName); + }); + + 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 rendered = key is string s + ? $"\"{s}\"" + : Convert.ToString(key, CultureInfo.InvariantCulture) ?? key.GetType().Name; + string keyPart = $"[{rendered}]"; + 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) + { + 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) + { + Path = path; + Reason = reason; + ExpectedText = expectedText; + ActualText = actualText; + } + + internal string Path { get; } + + internal string Reason { get; } + + internal string? ExpectedText { get; } + + internal string? ActualText { get; } + + internal static EquivalenceMismatch ValueMismatch(string path, object? expected, object? actual) + => new( + path, + FrameworkMessages.AreEquivalentMismatchValue, + AssertionValueRenderer.RenderValue(expected), + AssertionValueRenderer.RenderValue(actual)); + + internal static EquivalenceMismatch NullMismatch(string path, object? expected, object? actual) + => new( + path, + FrameworkMessages.AreEquivalentMismatchNull, + AssertionValueRenderer.RenderValue(expected), + AssertionValueRenderer.RenderValue(actual)); + + 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); + + internal static EquivalenceMismatch TopologyMismatch(string path) + => new( + path, + FrameworkMessages.AreEquivalentMismatchTopology, + expectedText: null, + actualText: null); + + 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)); + + internal static EquivalenceMismatch MissingKey(string path, object key) + => new( + path, + string.Format(CultureInfo.CurrentCulture, FrameworkMessages.AreEquivalentMismatchMissingKey, AssertionValueRenderer.RenderValue(key)), + expectedText: null, + actualText: null); + + internal static EquivalenceMismatch UnexpectedKey(string path, object key) + => new( + path, + string.Format(CultureInfo.CurrentCulture, FrameworkMessages.AreEquivalentMismatchUnexpectedKey, AssertionValueRenderer.RenderValue(key)), + expectedText: null, + actualText: null); + + internal static EquivalenceMismatch MissingMember(string path, string memberName) + => new( + path, + string.Format(CultureInfo.CurrentCulture, FrameworkMessages.AreEquivalentMismatchMissingMember, memberName), + expectedText: null, + actualText: null); + + internal static EquivalenceMismatch ExtraMembers(string path, IReadOnlyList extras) + => new( + path, + string.Format(CultureInfo.CurrentCulture, FrameworkMessages.AreEquivalentMismatchExtraMembers, string.Join(", ", extras)), + expectedText: null, + actualText: null); + + 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); + + 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); + + 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); + } +} diff --git a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs new file mode 100644 index 0000000000..beeea58606 --- /dev/null +++ b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs @@ -0,0 +1,294 @@ +// 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. + /// + /// + /// + /// The comparison rules are: + /// + /// + /// + /// + /// Reference-equal values (including both ) are considered equivalent. + /// + /// + /// + /// + /// Primitive-like types (numerics, , , , + /// enums, , , , + /// , , , ) are compared with + /// . + /// + /// + /// + /// + /// 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. + /// + /// + /// + /// + 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. + /// + /// + /// 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. + /// + 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 not null) + { + return; + } + + ReportAssertAreNotEquivalentFailed(notExpected, actual, strict, message, notExpectedExpression, actualExpression); + } + + [DoesNotReturn] + private static void ReportAssertAreEquivalentFailed(EquivalenceMismatch mismatch, bool strict, string? userMessage, string expectedExpression, string actualExpression) + { + string locationLine = string.Format( + CultureInfo.CurrentCulture, + FrameworkMessages.AreEquivalentMismatchAt, + mismatch.Path.Length == 0 ? FrameworkMessages.AreEquivalentRootPath : mismatch.Path, + mismatch.Reason); + + string summary = strict + ? FrameworkMessages.AreEquivalentFailedSummaryStrict + : FrameworkMessages.AreEquivalentFailedSummary; + + 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("expected:", mismatch.ExpectedText) + .AddLine("actual:", mismatch.ActualText); + structured.WithEvidence(evidence); + structured.WithExpectedAndActual(mismatch.ExpectedText, mismatch.ActualText); + } + + structured.WithCallSiteExpression(FormatCallSiteExpression("Assert.AreEquivalent", expectedExpression, actualExpression, "", "")); + + 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("notExpected:", 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..5a33202e8a 100644 --- a/src/TestFramework/TestFramework/Resources/FrameworkMessages.resx +++ b/src/TestFramework/TestFramework/Resources/FrameworkMessages.resx @@ -159,6 +159,68 @@ Expected any value except:<{1}>. Actual:<{2}>. {0} + + Expected values to be structurally equivalent. + + + Expected values to be structurally equivalent (strict mode). + + + Expected values to NOT be structurally equivalent. + + + Expected values to NOT be structurally equivalent (strict mode). + + + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + + + 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. + + + values are not equal. + + + one side is null, the other is not. + + + incompatible types (expected '{0}', actual '{1}'). + + + collections differ in length (expected {0} elements, actual {1}). + + + key {0} present on expected is missing from actual. + + + key {0} present on actual is not on expected. + + + member '{0}' present on expected is missing from actual. + + + actual has unexpected members not present on expected: {0}. + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + IEquatable.Equals threw {0}: {1}. + + + reading the expected dictionary threw {0}: {1}. + + + reading the actual dictionary threw {0}: {1}. + + + reading the expected member threw {0}: {1}. + + + reading the actual member threw {0}: {1}. + Expected a difference greater than <{3}> between expected value <{1}> and actual value <{2}>. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.cs.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.cs.xlf index 4ad5deac23..fef1e7f268 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.cs.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.cs.xlf @@ -68,6 +68,96 @@ 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}. + + + + 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}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + 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 +168,16 @@ Očekáván rozdíl, který je větší jak <{3}> mezi očekávanou hodnotou <{1}> a aktuální hodnotou <{2}>. {0} + + Expected values to NOT be structurally equivalent. + Expected values to NOT be structurally equivalent. + + + + Expected values to NOT be structurally equivalent (strict mode). + Expected values to NOT be structurally equivalent (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..9177131497 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.de.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.de.xlf @@ -68,6 +68,96 @@ 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}. + + + + 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}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + 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 +168,16 @@ Es wurde eine Differenz größer als <{3}> zwischen dem erwarteten Wert <{1}> und dem tatsächlichen Wert <{2}> erwartet. {0} + + Expected values to NOT be structurally equivalent. + Expected values to NOT be structurally equivalent. + + + + Expected values to NOT be structurally equivalent (strict mode). + Expected values to NOT be structurally equivalent (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..ceb9279a4c 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.es.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.es.xlf @@ -68,6 +68,96 @@ 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}. + + + + 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}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + 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 +168,16 @@ Se esperaba una diferencia mayor que <{3}> entre el valor esperado <{1}> y el valor actual <{2}>. {0} + + Expected values to NOT be structurally equivalent. + Expected values to NOT be structurally equivalent. + + + + Expected values to NOT be structurally equivalent (strict mode). + Expected values to NOT be structurally equivalent (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..d2caeb021a 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.fr.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.fr.xlf @@ -68,6 +68,96 @@ 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}. + + + + 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}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + 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 +168,16 @@ Différence attendue supérieure à <{3}> comprise entre la valeur attendue <{1}> et la valeur réelle <{2}>. {0} + + Expected values to NOT be structurally equivalent. + Expected values to NOT be structurally equivalent. + + + + Expected values to NOT be structurally equivalent (strict mode). + Expected values to NOT be structurally equivalent (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..447639e16f 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.it.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.it.xlf @@ -68,6 +68,96 @@ 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}. + + + + 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}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + 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 +168,16 @@ Prevista una differenza maggiore di <{3}> tra il valore previsto <{1}> e il valore effettivo <{2}>. {0} + + Expected values to NOT be structurally equivalent. + Expected values to NOT be structurally equivalent. + + + + Expected values to NOT be structurally equivalent (strict mode). + Expected values to NOT be structurally equivalent (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..1c5e8856b3 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ja.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ja.xlf @@ -68,6 +68,96 @@ 期待される文字列の長さは {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}. + + + + 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}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + 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 +168,16 @@ 指定する値 <{1}> と実際の値 <{2}> との間には、<{3}> を超える差が必要です。{0} + + Expected values to NOT be structurally equivalent. + Expected values to NOT be structurally equivalent. + + + + Expected values to NOT be structurally equivalent (strict mode). + Expected values to NOT be structurally equivalent (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..8595bc553e 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ko.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ko.xlf @@ -68,6 +68,96 @@ 문자열 길이 {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}. + + + + 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}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + 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 +168,16 @@ 예상 값 <{1}>과(와) 실제 값 <{2}>의 차이가 <{3}>보다 커야 합니다. {0} + + Expected values to NOT be structurally equivalent. + Expected values to NOT be structurally equivalent. + + + + Expected values to NOT be structurally equivalent (strict mode). + Expected values to NOT be structurally equivalent (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..f61979d57a 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pl.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pl.xlf @@ -68,6 +68,96 @@ 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}. + + + + 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}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + 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 +168,16 @@ Oczekiwano różnicy większej niż <{3}> pomiędzy oczekiwaną wartością <{1}> a rzeczywistą wartością <{2}>. {0} + + Expected values to NOT be structurally equivalent. + Expected values to NOT be structurally equivalent. + + + + Expected values to NOT be structurally equivalent (strict mode). + Expected values to NOT be structurally equivalent (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..0e5584406c 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pt-BR.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pt-BR.xlf @@ -68,6 +68,96 @@ 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}. + + + + 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}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + 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 +168,16 @@ Esperada uma diferença maior que <{3}> entre o valor esperado <{1}> e o valor real <{2}>. {0} + + Expected values to NOT be structurally equivalent. + Expected values to NOT be structurally equivalent. + + + + Expected values to NOT be structurally equivalent (strict mode). + Expected values to NOT be structurally equivalent (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..76682765ae 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ru.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ru.xlf @@ -68,6 +68,96 @@ Ожидалась длина строки: {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}. + + + + 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}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + 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 +168,16 @@ Между ожидаемым значением <{1}> и фактическим значением <{2}> требуется разница более чем <{3}>. {0} + + Expected values to NOT be structurally equivalent. + Expected values to NOT be structurally equivalent. + + + + Expected values to NOT be structurally equivalent (strict mode). + Expected values to NOT be structurally equivalent (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..0118fb5b9d 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.tr.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.tr.xlf @@ -68,6 +68,96 @@ 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}. + + + + 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}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + 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 +168,16 @@ Beklenen değer <{1}> ile gerçek değer <{2}> arasında, şundan büyük olan fark bekleniyor: <{3}>. {0} + + Expected values to NOT be structurally equivalent. + Expected values to NOT be structurally equivalent. + + + + Expected values to NOT be structurally equivalent (strict mode). + Expected values to NOT be structurally equivalent (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..d13b77e02b 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hans.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hans.xlf @@ -68,6 +68,96 @@ 字符串长度应为 {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}. + + + + 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}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + 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 +168,16 @@ 预期值 <{1}> 和实际值 <{2}> 之间的差应大于 <{3}>。{0} + + Expected values to NOT be structurally equivalent. + Expected values to NOT be structurally equivalent. + + + + Expected values to NOT be structurally equivalent (strict mode). + Expected values to NOT be structurally equivalent (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..6a3971eaa2 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hant.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hant.xlf @@ -68,6 +68,96 @@ 預期的字串長度為 {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}. + + + + 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}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + 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 +168,16 @@ 預期值 <{1}> 和實際值 <{2}> 之間的預期差異大於 <{3}>。{0} + + Expected values to NOT be structurally equivalent. + Expected values to NOT be structurally equivalent. + + + + Expected values to NOT be structurally equivalent (strict mode). + Expected values to NOT be structurally equivalent (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..dd979a1a69 --- /dev/null +++ b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs @@ -0,0 +1,914 @@ +// 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_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_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_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_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_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); + } + + #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)"); + } + + #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("*NOT be structurally equivalent*"); + } + + 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 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 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 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 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 +} From 19b9599ed13cb77f8f0cddae8cd462ba74d5b4b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Fri, 15 May 2026 20:17:35 +0200 Subject: [PATCH 2/8] Address AreEquivalent review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Assert.AreEquivalent.Comparer.cs | 168 ++++++++++++------ .../Assertions/Assert.AreEquivalent.cs | 70 ++++++-- .../Resources/FrameworkMessages.resx | 15 ++ .../Resources/xlf/FrameworkMessages.cs.xlf | 25 +++ .../Resources/xlf/FrameworkMessages.de.xlf | 25 +++ .../Resources/xlf/FrameworkMessages.es.xlf | 25 +++ .../Resources/xlf/FrameworkMessages.fr.xlf | 25 +++ .../Resources/xlf/FrameworkMessages.it.xlf | 25 +++ .../Resources/xlf/FrameworkMessages.ja.xlf | 25 +++ .../Resources/xlf/FrameworkMessages.ko.xlf | 25 +++ .../Resources/xlf/FrameworkMessages.pl.xlf | 25 +++ .../Resources/xlf/FrameworkMessages.pt-BR.xlf | 25 +++ .../Resources/xlf/FrameworkMessages.ru.xlf | 25 +++ .../Resources/xlf/FrameworkMessages.tr.xlf | 25 +++ .../xlf/FrameworkMessages.zh-Hans.xlf | 25 +++ .../xlf/FrameworkMessages.zh-Hant.xlf | 25 +++ .../AssertTests.AreEquivalentTests.cs | 91 ++++++++++ 17 files changed, 609 insertions(+), 60 deletions(-) diff --git a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs index 918f458230..b655211a64 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs @@ -11,6 +11,7 @@ public sealed partial class Assert 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(); @@ -31,9 +32,9 @@ internal EquivalenceComparer(bool strict) => _strict = strict; internal EquivalenceMismatch? Compare(T? expected, T? actual) - => Compare(expected, actual, typeof(T), path: string.Empty); + => Compare(expected, actual, typeof(T), path: string.Empty, depth: 0); - private EquivalenceMismatch? Compare(object? expected, object? actual, Type declaredType, string path) + private EquivalenceMismatch? Compare(object? expected, object? actual, Type declaredType, string path, int depth) { if (ReferenceEquals(expected, actual)) { @@ -45,15 +46,32 @@ internal EquivalenceComparer(bool strict) return EquivalenceMismatch.NullMismatch(path, expected, actual); } + if (depth > MaxComparisonDepth) + { + return EquivalenceMismatch.MaxDepthExceeded(path, MaxComparisonDepth); + } + Type expectedRuntimeType = expected.GetType(); Type actualRuntimeType = actual.GetType(); - // Primitive-ish types: trust Equals. + 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 Equals(expected, actual) - ? null - : EquivalenceMismatch.ValueMismatch(path, expected, actual); + 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 @@ -79,6 +97,9 @@ internal EquivalenceComparer(bool strict) // 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)) @@ -105,7 +126,7 @@ internal EquivalenceComparer(bool strict) if (expectedIsDictionary && actualIsDictionary) { Type valueDeclaredType = GetDictionaryValueType(declaredType, expectedRuntimeType, actualRuntimeType); - return CompareDictionaries(expectedDict!, actualDict!, valueDeclaredType, path); + return CompareDictionaries(expectedDict!, actualDict!, valueDeclaredType, path, depth); } // If exactly one side is a dictionary, treat as a structural mismatch rather than @@ -118,13 +139,13 @@ internal EquivalenceComparer(bool strict) if (expected is IEnumerable expectedEnum && actual is IEnumerable actualEnum) { Type elementDeclaredType = GetEnumerableElementType(declaredType, expectedRuntimeType, actualRuntimeType); - return CompareEnumerables(expectedEnum, actualEnum, elementDeclaredType, path); + return CompareEnumerables(expectedEnum, actualEnum, elementDeclaredType, path, depth); } - return CompareMembers(expected, actual, expectedRuntimeType, actualRuntimeType, path); + return CompareMembers(expected, actual, expectedRuntimeType, actualRuntimeType, path, depth); } - private EquivalenceMismatch? CompareDictionaries(DictionaryView expected, DictionaryView actual, Type valueDeclaredType, string path) + private EquivalenceMismatch? CompareDictionaries(DictionaryView expected, DictionaryView actual, Type valueDeclaredType, string path, int depth) { EquivalenceMismatch? failure = ForEachEntry(expected, isExpected: true, path, kvp => { @@ -140,7 +161,7 @@ internal EquivalenceComparer(bool strict) ? lookup : !lookupResult.Found ? EquivalenceMismatch.MissingKey(childPath, kvp.Key) - : Compare(kvp.Value, lookupResult.Value, valueDeclaredType, childPath); + : Compare(kvp.Value, lookupResult.Value, valueDeclaredType, childPath, depth + 1); }); if (failure is not null) { @@ -247,11 +268,20 @@ internal EquivalenceComparer(bool strict) } } - private EquivalenceMismatch? CompareEnumerables(IEnumerable expected, IEnumerable actual, Type elementDeclaredType, string path) + private EquivalenceMismatch? CompareEnumerables(IEnumerable expected, IEnumerable actual, Type elementDeclaredType, string path, int depth) { // Materialize once so we can report length and walk in parallel. - List expectedItems = ToList(expected); - List actualItems = ToList(actual); + EquivalenceMismatch? failure = TryToList(expected, isExpected: true, path, out List expectedItems); + if (failure is not null) + { + return failure; + } + + failure = TryToList(actual, isExpected: false, path, out List actualItems); + if (failure is not null) + { + return failure; + } if (expectedItems.Count != actualItems.Count) { @@ -260,7 +290,7 @@ internal EquivalenceComparer(bool strict) for (int i = 0; i < expectedItems.Count; i++) { - EquivalenceMismatch? nested = Compare(expectedItems[i], actualItems[i], elementDeclaredType, AppendIndex(path, i)); + EquivalenceMismatch? nested = Compare(expectedItems[i], actualItems[i], elementDeclaredType, AppendIndex(path, i), depth + 1); if (nested is not null) { return nested; @@ -270,7 +300,7 @@ internal EquivalenceComparer(bool strict) return null; } - private EquivalenceMismatch? CompareMembers(object expected, object actual, Type expectedType, Type actualType, string path) + private EquivalenceMismatch? CompareMembers(object expected, object actual, Type expectedType, Type actualType, string path, int depth) { MemberLookup expectedMembers = GetMembers(expectedType); @@ -291,7 +321,6 @@ internal EquivalenceComparer(bool strict) if (extras is { Count: > 0 }) { - extras.Sort(StringComparer.Ordinal); return EquivalenceMismatch.ExtraMembers(path, extras); } } @@ -334,7 +363,7 @@ internal EquivalenceComparer(bool strict) return EquivalenceMismatch.MemberAccessFailure(childPath, isExpected: false, ex); } - EquivalenceMismatch? nested = Compare(expectedValue, actualValue, member.MemberType, childPath); + EquivalenceMismatch? nested = Compare(expectedValue, actualValue, member.MemberType, childPath, depth + 1); if (nested is not null) { return nested; @@ -344,19 +373,32 @@ internal EquivalenceComparer(bool strict) return null; } - private static List ToList(IEnumerable source) + private static EquivalenceMismatch? TryToList(IEnumerable source, bool isExpected, string path, out List list) { + try + { #pragma warning disable IDE0028 // Collection initialization can be simplified - we want the capacity-aware ctor when ICollection is available. - List list = source is ICollection collection - ? new List(collection.Count) - : []; + list = source is ICollection collection + ? new List(collection.Count) + : []; #pragma warning restore IDE0028 - foreach (object? item in source) + foreach (object? item in source) + { + list.Add(item); + } + + return null; + } + catch (TargetInvocationException tie) { - list.Add(item); + list = []; + return EquivalenceMismatch.EnumerationFailure(path, isExpected, tie.InnerException ?? tie); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + list = []; + return EquivalenceMismatch.EnumerationFailure(path, isExpected, ex); } - - return list; } private enum IEquatableOutcome @@ -521,13 +563,7 @@ private static bool IsPrimitiveLike(Type type) // DateOnly / TimeOnly / Half / Int128 exist only on newer TFMs; match by full name to avoid #if. string? fullName = t.FullName; - if (fullName is "System.DateOnly" or "System.TimeOnly" or "System.Half" or "System.Int128" or "System.UInt128") - { - return true; - } - - // Compare reflection types as primitive (they all derive from System.Type). - return typeof(Type).IsAssignableFrom(t); + return fullName is "System.DateOnly" or "System.TimeOnly" or "System.Half" or "System.Int128" or "System.UInt128"; }); private static bool TryCreateDictionaryView(object value, out DictionaryView? view) @@ -633,10 +669,7 @@ private static string AppendIndex(string parent, int index) private static string AppendDictionaryKey(string parent, object key) { - string rendered = key is string s - ? $"\"{s}\"" - : Convert.ToString(key, CultureInfo.InvariantCulture) ?? key.GetType().Name; - string keyPart = $"[{rendered}]"; + string keyPart = $"[{AssertionValueRenderer.RenderValue(key)}]"; return parent.Length == 0 ? keyPart : parent + keyPart; } } @@ -889,12 +922,13 @@ internal MemberAccessor(string name, Type memberType, FieldInfo field) /// private sealed class EquivalenceMismatch { - private EquivalenceMismatch(string path, string reason, string? expectedText, string? actualText) + 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; } @@ -905,68 +939,79 @@ private EquivalenceMismatch(string path, string reason, string? expectedText, st 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)); + 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)); + 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); + actualType.FullName ?? actualType.Name, + isComparisonFailure: false); internal static EquivalenceMismatch TopologyMismatch(string path) => new( path, FrameworkMessages.AreEquivalentMismatchTopology, expectedText: null, - actualText: 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)); + 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); + 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); + 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); + 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); + actualText: null, + isComparisonFailure: false); internal static EquivalenceMismatch IEquatableThrew(string path, Exception thrown) => new( @@ -977,7 +1022,8 @@ internal static EquivalenceMismatch IEquatableThrew(string path, Exception throw thrown.GetType().Name, thrown.Message), expectedText: null, - actualText: null); + actualText: null, + isComparisonFailure: true); internal static EquivalenceMismatch DictionaryAccessFailure(string path, bool isExpected, Exception inner) => new( @@ -988,7 +1034,20 @@ internal static EquivalenceMismatch DictionaryAccessFailure(string path, bool is inner.GetType().Name, inner.Message), expectedText: null, - actualText: 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( @@ -999,6 +1058,15 @@ internal static EquivalenceMismatch MemberAccessFailure(string path, bool isExpe inner.GetType().Name, inner.Message), expectedText: null, - actualText: 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 index beeea58606..c1b3c9ce34 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs @@ -39,6 +39,11 @@ public sealed partial class Assert /// /// /// + /// 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: /// /// @@ -100,6 +105,12 @@ public sealed partial class Assert /// 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 = "") @@ -182,7 +193,8 @@ public static void AreEquivalent(T? expected, T? actual, bool strict, string? /// Users shouldn't pass a value for this parameter. /// /// - /// Thrown if and are structurally equivalent. + /// Thrown if and are structurally equivalent, + /// or if the structural comparison cannot be completed. /// /// /// See and @@ -223,22 +235,64 @@ public static void AreNotEquivalent(T? notExpected, T? actual, string? messag /// Users shouldn't pass a value for this parameter. /// /// - /// Thrown if and are structurally equivalent. + /// 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 not null) + if (mismatch is null) { - return; + ReportAssertAreNotEquivalentFailed(notExpected, actual, strict, message, notExpectedExpression, actualExpression); } - ReportAssertAreNotEquivalentFailed(notExpected, actual, strict, message, notExpectedExpression, actualExpression); + if (mismatch.IsComparisonFailure) + { + ReportAssertAreNotEquivalentComparisonFailed(mismatch, strict, message, notExpectedExpression, actualExpression); + } + + return; } [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, + "", + ""); + } + + [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, + "", + ""); + } + + [DoesNotReturn] + private static void ReportAssertEquivalenceMismatch(EquivalenceMismatch mismatch, string summary, string? userMessage, string assertionMethodName, string leftExpression, string rightExpression, string leftPlaceholder, string rightPlaceholder) { string locationLine = string.Format( CultureInfo.CurrentCulture, @@ -246,10 +300,6 @@ private static void ReportAssertAreEquivalentFailed(EquivalenceMismatch mismatch mismatch.Path.Length == 0 ? FrameworkMessages.AreEquivalentRootPath : mismatch.Path, mismatch.Reason); - string summary = strict - ? FrameworkMessages.AreEquivalentFailedSummaryStrict - : FrameworkMessages.AreEquivalentFailedSummary; - StructuredAssertionMessage structured = new(summary); structured.WithAdditionalSummaryLine(locationLine); structured.WithUserMessage(userMessage); @@ -263,7 +313,7 @@ private static void ReportAssertAreEquivalentFailed(EquivalenceMismatch mismatch structured.WithExpectedAndActual(mismatch.ExpectedText, mismatch.ActualText); } - structured.WithCallSiteExpression(FormatCallSiteExpression("Assert.AreEquivalent", expectedExpression, actualExpression, "", "")); + structured.WithCallSiteExpression(FormatCallSiteExpression(assertionMethodName, leftExpression, rightExpression, leftPlaceholder, rightPlaceholder)); ReportAssertFailed(structured); } diff --git a/src/TestFramework/TestFramework/Resources/FrameworkMessages.resx b/src/TestFramework/TestFramework/Resources/FrameworkMessages.resx index 5a33202e8a..2ec10db55a 100644 --- a/src/TestFramework/TestFramework/Resources/FrameworkMessages.resx +++ b/src/TestFramework/TestFramework/Resources/FrameworkMessages.resx @@ -171,6 +171,12 @@ Expected values to NOT be structurally equivalent (strict mode). + + Could not complete structural comparison. + + + Could not complete structural comparison (strict mode). + <root> Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. @@ -206,6 +212,9 @@ graph topology differs (the same reference on one side appears paired with different references on the other side). + + comparison exceeded the maximum supported depth of {0}. + IEquatable.Equals threw {0}: {1}. @@ -215,6 +224,12 @@ reading the actual dictionary threw {0}: {1}. + + enumerating the expected collection threw {0}: {1}. + + + enumerating the actual collection threw {0}: {1}. + reading the expected member threw {0}: {1}. diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.cs.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.cs.xlf index fef1e7f268..920eb69c1d 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.cs.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.cs.xlf @@ -83,6 +83,11 @@ 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}. @@ -98,6 +103,11 @@ 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}. @@ -118,6 +128,11 @@ 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. @@ -168,6 +183,16 @@ 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 NOT be structurally equivalent. Expected values to NOT be structurally equivalent. diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.de.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.de.xlf index 9177131497..99c2fd5a67 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.de.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.de.xlf @@ -83,6 +83,11 @@ 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}. @@ -98,6 +103,11 @@ 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}. @@ -118,6 +128,11 @@ 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. @@ -168,6 +183,16 @@ 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 NOT be structurally equivalent. Expected values to NOT be structurally equivalent. diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.es.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.es.xlf index ceb9279a4c..33eab9cf77 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.es.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.es.xlf @@ -83,6 +83,11 @@ 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}. @@ -98,6 +103,11 @@ 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}. @@ -118,6 +128,11 @@ 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. @@ -168,6 +183,16 @@ 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 NOT be structurally equivalent. Expected values to NOT be structurally equivalent. diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.fr.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.fr.xlf index d2caeb021a..d6cd4df375 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.fr.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.fr.xlf @@ -83,6 +83,11 @@ 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}. @@ -98,6 +103,11 @@ 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}. @@ -118,6 +128,11 @@ 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. @@ -168,6 +183,16 @@ 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 NOT be structurally equivalent. Expected values to NOT be structurally equivalent. diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.it.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.it.xlf index 447639e16f..2e283764cf 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.it.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.it.xlf @@ -83,6 +83,11 @@ 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}. @@ -98,6 +103,11 @@ 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}. @@ -118,6 +128,11 @@ 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. @@ -168,6 +183,16 @@ 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 NOT be structurally equivalent. Expected values to NOT be structurally equivalent. diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ja.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ja.xlf index 1c5e8856b3..a5ffc8c771 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ja.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ja.xlf @@ -83,6 +83,11 @@ 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}. @@ -98,6 +103,11 @@ 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}. @@ -118,6 +128,11 @@ 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. @@ -168,6 +183,16 @@ 指定する値 <{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 NOT be structurally equivalent. Expected values to NOT be structurally equivalent. diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ko.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ko.xlf index 8595bc553e..a9f9888cb7 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ko.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ko.xlf @@ -83,6 +83,11 @@ 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}. @@ -98,6 +103,11 @@ 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}. @@ -118,6 +128,11 @@ 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. @@ -168,6 +183,16 @@ 예상 값 <{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 NOT be structurally equivalent. Expected values to NOT be structurally equivalent. diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pl.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pl.xlf index f61979d57a..e0a172ee10 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pl.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pl.xlf @@ -83,6 +83,11 @@ 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}. @@ -98,6 +103,11 @@ 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}. @@ -118,6 +128,11 @@ 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. @@ -168,6 +183,16 @@ 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 NOT be structurally equivalent. Expected values to NOT be structurally equivalent. diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pt-BR.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pt-BR.xlf index 0e5584406c..7e62b2f72b 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pt-BR.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pt-BR.xlf @@ -83,6 +83,11 @@ 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}. @@ -98,6 +103,11 @@ 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}. @@ -118,6 +128,11 @@ 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. @@ -168,6 +183,16 @@ 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 NOT be structurally equivalent. Expected values to NOT be structurally equivalent. diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ru.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ru.xlf index 76682765ae..fcc488e0b8 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ru.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ru.xlf @@ -83,6 +83,11 @@ 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}. @@ -98,6 +103,11 @@ 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}. @@ -118,6 +128,11 @@ 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. @@ -168,6 +183,16 @@ Между ожидаемым значением <{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 NOT be structurally equivalent. Expected values to NOT be structurally equivalent. diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.tr.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.tr.xlf index 0118fb5b9d..248e338718 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.tr.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.tr.xlf @@ -83,6 +83,11 @@ 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}. @@ -98,6 +103,11 @@ 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}. @@ -118,6 +128,11 @@ 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. @@ -168,6 +183,16 @@ 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 NOT be structurally equivalent. Expected values to NOT be structurally equivalent. diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hans.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hans.xlf index d13b77e02b..717411702f 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hans.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hans.xlf @@ -83,6 +83,11 @@ 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}. @@ -98,6 +103,11 @@ 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}. @@ -118,6 +128,11 @@ 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. @@ -168,6 +183,16 @@ 预期值 <{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 NOT be structurally equivalent. Expected values to NOT be structurally equivalent. diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hant.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hant.xlf index 6a3971eaa2..a012b985f0 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hant.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hant.xlf @@ -83,6 +83,11 @@ 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}. @@ -98,6 +103,11 @@ 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}. @@ -118,6 +128,11 @@ 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. @@ -168,6 +183,16 @@ 預期值 <{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 NOT be structurally equivalent. Expected values to NOT be structurally equivalent. diff --git a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs index dd979a1a69..d0ae5a3bdf 100644 --- a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs +++ b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs @@ -43,6 +43,22 @@ public void AreEquivalent_EqualPrimitives_Passes() 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); @@ -231,6 +247,16 @@ public void AreEquivalent_Dictionary_NonStringKey_DifferentValue_FailsWithKeyInP .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 @@ -339,6 +365,15 @@ public void AreEquivalent_DictionaryThrowsFromTryGetValue_FailsWithExpectedDiagn .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_ReadOnlyDictionaryOnly_RespectsSourceComparer() { // Custom IReadOnlyDictionary<,>-only type backed by a case-insensitive Dictionary should @@ -396,6 +431,15 @@ public void AreNotEquivalent_IEquatableSemantics_Fails() 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; @@ -462,6 +506,15 @@ public void AreEquivalent_CrossCycles_Detected_Pass() Assert.AreEquivalent(a, b); } + public void AreEquivalent_DeepGraph_ReportsMaxDepthExceeded() + { + DeepNode expected = CreateDeepNodeChain(300); + DeepNode actual = CreateDeepNodeChain(300); + Action act = () => Assert.AreEquivalent(expected, actual); + act.Should().Throw() + .And.Message.Should().Contain("maximum supported depth of 256"); + } + #endregion #region AreEquivalent — strict mode @@ -569,6 +622,19 @@ public void AreEquivalent_CallSiteExpression_IsRendered() .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"); + AssertFailedException ex = act.Should().Throw().Which; + ex.Message.Should().StartWith("Assertion failed. Expected values to be structurally equivalent."); + ex.Message.Should().Contain($"{Environment.NewLine}Mismatch at '': values are not equal."); + ex.Message.Should().Contain($"{Environment.NewLine}numbers should match"); + ex.Message.Should().Contain($"{Environment.NewLine}{Environment.NewLine}expected: 1{Environment.NewLine}actual: 2"); + ex.Message.Should().Contain($"{Environment.NewLine}{Environment.NewLine}Assert.AreEquivalent(expected, actual)"); + } + #endregion #region AreNotEquivalent @@ -611,6 +677,19 @@ public void AreNotEquivalent_UserMessage_IsIncluded() #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; + } + private sealed class Person { public Person(string name, int age) @@ -730,6 +809,11 @@ private sealed class Node 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. @@ -868,6 +952,13 @@ public override int Value } } + private sealed class ThrowingEnumerable : IEnumerable + { + public IEnumerator GetEnumerator() => throw new InvalidOperationException("enumeration boom"); + + IEnumerator IEnumerable.GetEnumerator() => throw new InvalidOperationException("enumeration boom"); + } + private sealed class ThrowingDictionary : IDictionary { public int this[string key] From 8f73a91fa4e416c69f51f07f2879755c9b09f178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Fri, 15 May 2026 20:27:58 +0200 Subject: [PATCH 3/8] Tighten AreEquivalent RFC test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AssertTests.AreEquivalentTests.cs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs index d0ae5a3bdf..8e76eea2b9 100644 --- a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs +++ b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs @@ -627,12 +627,18 @@ public void AreEquivalent_FailureMessage_FollowsRfc012Layout() int expected = 1; int actual = 2; Action act = () => Assert.AreEquivalent(expected, actual, "numbers should match"); - AssertFailedException ex = act.Should().Throw().Which; - ex.Message.Should().StartWith("Assertion failed. Expected values to be structurally equivalent."); - ex.Message.Should().Contain($"{Environment.NewLine}Mismatch at '': values are not equal."); - ex.Message.Should().Contain($"{Environment.NewLine}numbers should match"); - ex.Message.Should().Contain($"{Environment.NewLine}{Environment.NewLine}expected: 1{Environment.NewLine}actual: 2"); - ex.Message.Should().Contain($"{Environment.NewLine}{Environment.NewLine}Assert.AreEquivalent(expected, actual)"); + 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 From d1910d06a4c5458637358a6b7765dd0aa8f91d12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Fri, 15 May 2026 21:04:37 +0200 Subject: [PATCH 4/8] Address AreEquivalent review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Assert.AreEquivalent.Comparer.cs | 182 +++++++++++++++--- .../Assertions/Assert.AreEquivalent.cs | 5 +- .../Resources/FrameworkMessages.resx | 90 ++++----- .../AssertTests.AreEquivalentTests.cs | 47 ++++- 4 files changed, 246 insertions(+), 78 deletions(-) diff --git a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs index b655211a64..5e9806bf38 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs @@ -46,7 +46,7 @@ internal EquivalenceComparer(bool strict) return EquivalenceMismatch.NullMismatch(path, expected, actual); } - if (depth > MaxComparisonDepth) + if (depth >= MaxComparisonDepth) { return EquivalenceMismatch.MaxDepthExceeded(path, MaxComparisonDepth); } @@ -270,34 +270,84 @@ internal EquivalenceComparer(bool strict) private EquivalenceMismatch? CompareEnumerables(IEnumerable expected, IEnumerable actual, Type elementDeclaredType, string path, int depth) { - // Materialize once so we can report length and walk in parallel. - EquivalenceMismatch? failure = TryToList(expected, isExpected: true, path, out List expectedItems); + EquivalenceMismatch? failure = TryGetEnumerator(expected, isExpected: true, path, out IEnumerator expectedEnumerator); if (failure is not null) { return failure; } - failure = TryToList(actual, isExpected: false, path, out List actualItems); + failure = TryGetEnumerator(actual, isExpected: false, path, out IEnumerator actualEnumerator); if (failure is not null) { + DisposeEnumerator(expectedEnumerator); return failure; } - if (expectedItems.Count != actualItems.Count) - { - return EquivalenceMismatch.LengthMismatch(path, expectedItems.Count, actualItems.Count); - } - - for (int i = 0; i < expectedItems.Count; i++) + try { - EquivalenceMismatch? nested = Compare(expectedItems[i], actualItems[i], elementDeclaredType, AppendIndex(path, i), depth + 1); - if (nested is not null) + int index = 0; + while (true) { - return nested; + 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); + if (failure is not null) + { + return failure; + } + + return 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++; } } - - return null; + finally + { + DisposeEnumerator(expectedEnumerator); + DisposeEnumerator(actualEnumerator); + } } private EquivalenceMismatch? CompareMembers(object expected, object actual, Type expectedType, Type actualType, string path, int depth) @@ -373,34 +423,112 @@ internal EquivalenceComparer(bool strict) return null; } - private static EquivalenceMismatch? TryToList(IEnumerable source, bool isExpected, string path, out List list) + private static EquivalenceMismatch? TryGetEnumerator(IEnumerable source, bool isExpected, string path, out IEnumerator enumerator) { try { -#pragma warning disable IDE0028 // Collection initialization can be simplified - we want the capacity-aware ctor when ICollection is available. - list = source is ICollection collection - ? new List(collection.Count) - : []; -#pragma warning restore IDE0028 - foreach (object? item in source) - { - list.Add(item); - } + enumerator = source.GetEnumerator(); + return null; + } + catch (TargetInvocationException tie) + { + enumerator = default!; + return EquivalenceMismatch.EnumerationFailure(path, isExpected, tie.InnerException ?? tie); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + 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) + { + hasNext = false; + return EquivalenceMismatch.EnumerationFailure(path, isExpected, tie.InnerException ?? tie); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + 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) { - list = []; + current = default; return EquivalenceMismatch.EnumerationFailure(path, isExpected, tie.InnerException ?? tie); } catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) { - list = []; + 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, diff --git a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs index c1b3c9ce34..140fda78c8 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs @@ -246,13 +246,10 @@ public static void AreNotEquivalent(T? notExpected, T? actual, bool strict, s { ReportAssertAreNotEquivalentFailed(notExpected, actual, strict, message, notExpectedExpression, actualExpression); } - - if (mismatch.IsComparisonFailure) + else if (mismatch.IsComparisonFailure) { ReportAssertAreNotEquivalentComparisonFailed(mismatch, strict, message, notExpectedExpression, actualExpression); } - - return; } [DoesNotReturn] diff --git a/src/TestFramework/TestFramework/Resources/FrameworkMessages.resx b/src/TestFramework/TestFramework/Resources/FrameworkMessages.resx index 2ec10db55a..e0864552ab 100644 --- a/src/TestFramework/TestFramework/Resources/FrameworkMessages.resx +++ b/src/TestFramework/TestFramework/Resources/FrameworkMessages.resx @@ -156,88 +156,88 @@ 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). - - Expected values to NOT be structurally equivalent. - - - Expected values to NOT be structurally equivalent (strict mode). - - - Could not complete structural comparison. + + reading the actual dictionary threw {0}: {1}. - - Could not complete structural comparison (strict mode). + + enumerating the actual collection threw {0}: {1}. - - <root> - Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + + 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. - - values are not equal. + + reading the expected dictionary threw {0}: {1}. - - one side is null, the other is not. + + enumerating the expected collection threw {0}: {1}. - - incompatible types (expected '{0}', actual '{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. - - key {0} present on actual is not on expected. - member '{0}' present on expected is missing from actual. - - actual has unexpected members not present on expected: {0}. + + 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). - - comparison exceeded the maximum supported depth of {0}. + + incompatible types (expected '{0}', actual '{1}'). - - IEquatable.Equals threw {0}: {1}. + + key {0} present on actual is not on expected. - - reading the expected dictionary threw {0}: {1}. + + values are not equal. - - reading the actual dictionary threw {0}: {1}. + + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. - - enumerating the expected collection threw {0}: {1}. + + Expected a difference greater than <{3}> between expected value <{1}> and actual value <{2}>. {0} - - enumerating the actual collection threw {0}: {1}. + + Expected any value except:<{1}>. Actual:<{2}>. {0} - - reading the expected member threw {0}: {1}. + + Could not complete structural comparison. - - reading the actual member threw {0}: {1}. + + Could not complete structural comparison (strict mode). - - Expected a difference greater than <{3}> between expected value <{1}> and actual value <{2}>. {0} + + Expected values to NOT be structurally equivalent. + + + Expected values to NOT be structurally equivalent (strict mode). Expected is <null>. {0} diff --git a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs index 8e76eea2b9..e0f05163c3 100644 --- a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs +++ b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs @@ -374,6 +374,15 @@ public void AreEquivalent_EnumerableThrows_FailsWithExpectedDiagnostic() .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 @@ -506,10 +515,17 @@ public void AreEquivalent_CrossCycles_Detected_Pass() 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(300); - DeepNode actual = CreateDeepNodeChain(300); + 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"); @@ -965,6 +981,33 @@ private sealed class ThrowingEnumerable : IEnumerable 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++) + { + if (i > _maxIndex) + { + throw new InvalidOperationException("enumerated past mismatch"); + } + + yield return _items[i]; + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + private sealed class ThrowingDictionary : IDictionary { public int this[string key] From c7ec8ee7b495c9c6efa997a9d13f1cd07c0eafbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Sat, 16 May 2026 10:59:42 +0200 Subject: [PATCH 5/8] Fix IDE0046 build errors and align AreNotEquivalent evidence label with RFC 012 - Use null-coalescing return in CompareEnumerables to satisfy IDE0046. - Use conditional throw in FailIfEnumeratedPastIndexEnumerable to satisfy IDE0046. - Rename evidence label 'notExpected:' to 'not expected:' to match RFC 012 convention used by other negated assertions (AreNotEqual, IsNotInstanceOfType). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Assertions/Assert.AreEquivalent.Comparer.cs | 7 +------ .../TestFramework/Assertions/Assert.AreEquivalent.cs | 2 +- .../Assertions/AssertTests.AreEquivalentTests.cs | 9 +++------ 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs index 5e9806bf38..7b7747e586 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs @@ -311,12 +311,7 @@ internal EquivalenceComparer(bool strict) } failure = TryGetEnumerableCount(actualEnumerator, actualHasNext, isExpected: false, path, index, out int actualCount); - if (failure is not null) - { - return failure; - } - - return EquivalenceMismatch.LengthMismatch(path, expectedCount, actualCount); + return failure ?? EquivalenceMismatch.LengthMismatch(path, expectedCount, actualCount); } return null; diff --git a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs index 140fda78c8..5b8c5298db 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs @@ -329,7 +329,7 @@ private static void ReportAssertAreNotEquivalentFailed(T? notExpected, T? act structured.WithUserMessage(userMessage); EvidenceBlock evidence = EvidenceBlock.Create() - .AddLine("notExpected:", notExpectedText) + .AddLine("not expected:", notExpectedText) .AddLine("actual:", actualText); structured.WithEvidence(evidence); structured.WithExpectedAndActual($"not equivalent to {notExpectedText}", actualText); diff --git a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs index e0f05163c3..c2dd5c10ae 100644 --- a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs +++ b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs @@ -996,12 +996,9 @@ public IEnumerator GetEnumerator() { for (int i = 0; i < _items.Length; i++) { - if (i > _maxIndex) - { - throw new InvalidOperationException("enumerated past mismatch"); - } - - yield return _items[i]; + yield return i > _maxIndex + ? throw new InvalidOperationException("enumerated past mismatch") + : _items[i]; } } From 50b5c61d44fb7c19385bb3df15c0f776bd523fb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Sat, 16 May 2026 11:45:15 +0200 Subject: [PATCH 6/8] Address expert-review findings: RFC 012 alignment and equivalence comparer hardening - Rephrase AreNotEquivalent summary as 'Expected values to be structurally different.' to match RFC 012's negated-verb convention (mirroring AreNotEqual). - Plumb evidence labels ('expected:'/'actual:' vs 'not expected:'/'actual:') through ReportAssertEquivalenceMismatch so future comparison-failure factories that populate expected/actual text won't leak the wrong label on the AreNotEquivalent path. - Deduplicate shadowed members in GetMembers: prefer most-derived DeclaringType so 'new'-shadowed properties/fields are resolved deterministically regardless of Reflection metadata ordering. Added regression tests. - Exclude UnitTestAssertException (Assert.Fail/Inconclusive) from blanket exception filters around member getters, enumeration, and dictionary ops so framework exceptions raised by user code propagate instead of being rewritten as comparison failures. - Remove dead 't.IsPointer' branch from IsPrimitiveLike. - Document the dead null guard in GenericDictionaryAccessors.Enumerate (kept for defense against custom IEnumerable implementations yielding boxed null). - Add comment explaining why non-generic IDictionary takes precedence over the generic interfaces in TryCreateDictionaryView. - Extend AreEquivalent XML primitive-like list with DateOnly, TimeOnly, Half, Int128, UInt128 (matches existing implementation on supporting TFMs). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Assert.AreEquivalent.Comparer.cs | 72 +++++++++++++------ .../Assertions/Assert.AreEquivalent.cs | 18 +++-- .../Resources/FrameworkMessages.resx | 4 +- .../Resources/xlf/FrameworkMessages.cs.xlf | 8 +-- .../Resources/xlf/FrameworkMessages.de.xlf | 8 +-- .../Resources/xlf/FrameworkMessages.es.xlf | 8 +-- .../Resources/xlf/FrameworkMessages.fr.xlf | 8 +-- .../Resources/xlf/FrameworkMessages.it.xlf | 8 +-- .../Resources/xlf/FrameworkMessages.ja.xlf | 8 +-- .../Resources/xlf/FrameworkMessages.ko.xlf | 8 +-- .../Resources/xlf/FrameworkMessages.pl.xlf | 8 +-- .../Resources/xlf/FrameworkMessages.pt-BR.xlf | 8 +-- .../Resources/xlf/FrameworkMessages.ru.xlf | 8 +-- .../Resources/xlf/FrameworkMessages.tr.xlf | 8 +-- .../xlf/FrameworkMessages.zh-Hans.xlf | 8 +-- .../xlf/FrameworkMessages.zh-Hant.xlf | 8 +-- .../AssertTests.AreEquivalentTests.cs | 33 ++++++++- 17 files changed, 148 insertions(+), 83 deletions(-) diff --git a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs index 7b7747e586..27593a18d0 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs @@ -207,7 +207,7 @@ internal EquivalenceComparer(bool strict) { return EquivalenceMismatch.DictionaryAccessFailure(path, isExpected, tie.InnerException ?? tie); } - catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not UnitTestAssertException) { return EquivalenceMismatch.DictionaryAccessFailure(path, isExpected, ex); } @@ -230,7 +230,7 @@ internal EquivalenceComparer(bool strict) { return EquivalenceMismatch.DictionaryAccessFailure(path, isExpected, tie.InnerException ?? tie); } - catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not UnitTestAssertException) { return EquivalenceMismatch.DictionaryAccessFailure(path, isExpected, ex); } @@ -261,7 +261,7 @@ internal EquivalenceComparer(bool strict) result = default!; return EquivalenceMismatch.DictionaryAccessFailure(path, isExpected, tie.InnerException ?? tie); } - catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not UnitTestAssertException) { result = default!; return EquivalenceMismatch.DictionaryAccessFailure(path, isExpected, ex); @@ -390,7 +390,7 @@ internal EquivalenceComparer(bool strict) { return EquivalenceMismatch.MemberAccessFailure(childPath, isExpected: true, ex.InnerException ?? ex); } - catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not UnitTestAssertException) { return EquivalenceMismatch.MemberAccessFailure(childPath, isExpected: true, ex); } @@ -403,7 +403,7 @@ internal EquivalenceComparer(bool strict) { return EquivalenceMismatch.MemberAccessFailure(childPath, isExpected: false, ex.InnerException ?? ex); } - catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not UnitTestAssertException) { return EquivalenceMismatch.MemberAccessFailure(childPath, isExpected: false, ex); } @@ -430,7 +430,7 @@ internal EquivalenceComparer(bool strict) enumerator = default!; return EquivalenceMismatch.EnumerationFailure(path, isExpected, tie.InnerException ?? tie); } - catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not UnitTestAssertException) { enumerator = default!; return EquivalenceMismatch.EnumerationFailure(path, isExpected, ex); @@ -449,7 +449,7 @@ internal EquivalenceComparer(bool strict) hasNext = false; return EquivalenceMismatch.EnumerationFailure(path, isExpected, tie.InnerException ?? tie); } - catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not UnitTestAssertException) { hasNext = false; return EquivalenceMismatch.EnumerationFailure(path, isExpected, ex); @@ -468,7 +468,7 @@ internal EquivalenceComparer(bool strict) current = default; return EquivalenceMismatch.EnumerationFailure(path, isExpected, tie.InnerException ?? tie); } - catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not UnitTestAssertException) { current = default; return EquivalenceMismatch.EnumerationFailure(path, isExpected, ex); @@ -561,7 +561,7 @@ private static IEquatableOutcome InvokeIEquatable(object expected, object actual thrown = ex.InnerException ?? ex; return IEquatableOutcome.Threw; } - catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not UnitTestAssertException) { thrown = ex; return IEquatableOutcome.Threw; @@ -670,7 +670,7 @@ private static Type GetDictionaryValueType(Type declaredType, Type expectedRunti private static bool IsPrimitiveLike(Type type) => IsPrimitiveLikeCache.GetOrAdd(type, static t => { - if (t.IsPrimitive || t.IsEnum || t.IsPointer) + if (t.IsPrimitive || t.IsEnum) { return true; } @@ -691,6 +691,11 @@ private static bool IsPrimitiveLike(Type type) 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); @@ -738,7 +743,13 @@ private static bool TryCreateDictionaryView(object value, out DictionaryView? vi private static MemberLookup GetMembers(Type type) => MemberCache.GetOrAdd(type, static t => { - List list = []; + // 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)) { @@ -753,7 +764,7 @@ private static MemberLookup GetMembers(Type type) continue; } - list.Add(new MemberAccessor(p.Name, p.PropertyType, p)); + TryRegisterMostDerived(byName, declaringTypes, p, new MemberAccessor(p.Name, p.PropertyType, p)); } foreach (FieldInfo f in t.GetFields(BindingFlags.Public | BindingFlags.Instance)) @@ -763,24 +774,39 @@ private static MemberLookup GetMembers(Type type) continue; } - list.Add(new MemberAccessor(f.Name, f.FieldType, f)); + TryRegisterMostDerived(byName, declaringTypes, f, new MemberAccessor(f.Name, f.FieldType, f)); } - // Sort alphabetically for stable diagnostics across runtimes. - list.Sort(static (a, b) => StringComparer.Ordinal.Compare(a.Name, b.Name)); - - MemberAccessor[] sorted = list.ToArray(); - Dictionary byName = new(sorted.Length, StringComparer.Ordinal); - foreach (MemberAccessor m in sorted) + var sorted = new MemberAccessor[byName.Count]; + int i = 0; + foreach (MemberAccessor accessor in byName.Values) { - // Property/field name shadowing is rare; keep the first one we encounter - // (alphabetical order is deterministic). - byName[m.Name] = m; + 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; @@ -986,6 +1012,8 @@ internal static GenericDictionaryAccessors Build(Type dictionaryInterface) { 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; diff --git a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs index 5b8c5298db..25b80880ec 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.cs @@ -57,7 +57,9 @@ public sealed partial class Assert /// 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. /// /// /// @@ -267,7 +269,9 @@ private static void ReportAssertAreEquivalentFailed(EquivalenceMismatch mismatch expectedExpression, actualExpression, "", - ""); + "", + "expected:", + "actual:"); } [DoesNotReturn] @@ -285,11 +289,13 @@ private static void ReportAssertAreNotEquivalentComparisonFailed(EquivalenceMism 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) + 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, @@ -304,8 +310,8 @@ private static void ReportAssertEquivalenceMismatch(EquivalenceMismatch mismatch if (mismatch.ExpectedText is not null && mismatch.ActualText is not null) { EvidenceBlock evidence = EvidenceBlock.Create() - .AddLine("expected:", mismatch.ExpectedText) - .AddLine("actual:", mismatch.ActualText); + .AddLine(leftLabel, mismatch.ExpectedText) + .AddLine(rightLabel, mismatch.ActualText); structured.WithEvidence(evidence); structured.WithExpectedAndActual(mismatch.ExpectedText, mismatch.ActualText); } diff --git a/src/TestFramework/TestFramework/Resources/FrameworkMessages.resx b/src/TestFramework/TestFramework/Resources/FrameworkMessages.resx index e0864552ab..9abd2ca3a9 100644 --- a/src/TestFramework/TestFramework/Resources/FrameworkMessages.resx +++ b/src/TestFramework/TestFramework/Resources/FrameworkMessages.resx @@ -234,10 +234,10 @@ Could not complete structural comparison (strict mode). - Expected values to NOT be structurally equivalent. + Expected values to be structurally different. - Expected values to NOT be structurally equivalent (strict mode). + 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 920eb69c1d..774cb58c1d 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.cs.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.cs.xlf @@ -194,13 +194,13 @@ - Expected values to NOT be structurally equivalent. - Expected values to NOT be structurally equivalent. + Expected values to be structurally different. + Expected values to be structurally different. - Expected values to NOT be structurally equivalent (strict mode). - Expected values to NOT be structurally equivalent (strict mode). + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.de.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.de.xlf index 99c2fd5a67..a18fe38b57 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.de.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.de.xlf @@ -194,13 +194,13 @@ - Expected values to NOT be structurally equivalent. - Expected values to NOT be structurally equivalent. + Expected values to be structurally different. + Expected values to be structurally different. - Expected values to NOT be structurally equivalent (strict mode). - Expected values to NOT be structurally equivalent (strict mode). + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.es.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.es.xlf index 33eab9cf77..eff13018e8 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.es.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.es.xlf @@ -194,13 +194,13 @@ - Expected values to NOT be structurally equivalent. - Expected values to NOT be structurally equivalent. + Expected values to be structurally different. + Expected values to be structurally different. - Expected values to NOT be structurally equivalent (strict mode). - Expected values to NOT be structurally equivalent (strict mode). + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.fr.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.fr.xlf index d6cd4df375..e5b9365813 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.fr.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.fr.xlf @@ -194,13 +194,13 @@ - Expected values to NOT be structurally equivalent. - Expected values to NOT be structurally equivalent. + Expected values to be structurally different. + Expected values to be structurally different. - Expected values to NOT be structurally equivalent (strict mode). - Expected values to NOT be structurally equivalent (strict mode). + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.it.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.it.xlf index 2e283764cf..832126a4fc 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.it.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.it.xlf @@ -194,13 +194,13 @@ - Expected values to NOT be structurally equivalent. - Expected values to NOT be structurally equivalent. + Expected values to be structurally different. + Expected values to be structurally different. - Expected values to NOT be structurally equivalent (strict mode). - Expected values to NOT be structurally equivalent (strict mode). + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ja.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ja.xlf index a5ffc8c771..d77f677844 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ja.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ja.xlf @@ -194,13 +194,13 @@ - Expected values to NOT be structurally equivalent. - Expected values to NOT be structurally equivalent. + Expected values to be structurally different. + Expected values to be structurally different. - Expected values to NOT be structurally equivalent (strict mode). - Expected values to NOT be structurally equivalent (strict mode). + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ko.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ko.xlf index a9f9888cb7..3c8a5436ca 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ko.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ko.xlf @@ -194,13 +194,13 @@ - Expected values to NOT be structurally equivalent. - Expected values to NOT be structurally equivalent. + Expected values to be structurally different. + Expected values to be structurally different. - Expected values to NOT be structurally equivalent (strict mode). - Expected values to NOT be structurally equivalent (strict mode). + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pl.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pl.xlf index e0a172ee10..6c85ca160a 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pl.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pl.xlf @@ -194,13 +194,13 @@ - Expected values to NOT be structurally equivalent. - Expected values to NOT be structurally equivalent. + Expected values to be structurally different. + Expected values to be structurally different. - Expected values to NOT be structurally equivalent (strict mode). - Expected values to NOT be structurally equivalent (strict mode). + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pt-BR.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pt-BR.xlf index 7e62b2f72b..a21237aea3 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pt-BR.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pt-BR.xlf @@ -194,13 +194,13 @@ - Expected values to NOT be structurally equivalent. - Expected values to NOT be structurally equivalent. + Expected values to be structurally different. + Expected values to be structurally different. - Expected values to NOT be structurally equivalent (strict mode). - Expected values to NOT be structurally equivalent (strict mode). + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ru.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ru.xlf index fcc488e0b8..fef2349561 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ru.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ru.xlf @@ -194,13 +194,13 @@ - Expected values to NOT be structurally equivalent. - Expected values to NOT be structurally equivalent. + Expected values to be structurally different. + Expected values to be structurally different. - Expected values to NOT be structurally equivalent (strict mode). - Expected values to NOT be structurally equivalent (strict mode). + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.tr.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.tr.xlf index 248e338718..79e1d5bbcd 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.tr.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.tr.xlf @@ -194,13 +194,13 @@ - Expected values to NOT be structurally equivalent. - Expected values to NOT be structurally equivalent. + Expected values to be structurally different. + Expected values to be structurally different. - Expected values to NOT be structurally equivalent (strict mode). - Expected values to NOT be structurally equivalent (strict mode). + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hans.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hans.xlf index 717411702f..917f8a8f31 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hans.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hans.xlf @@ -194,13 +194,13 @@ - Expected values to NOT be structurally equivalent. - Expected values to NOT be structurally equivalent. + Expected values to be structurally different. + Expected values to be structurally different. - Expected values to NOT be structurally equivalent (strict mode). - Expected values to NOT be structurally equivalent (strict mode). + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hant.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hant.xlf index a012b985f0..59865404f8 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hant.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hant.xlf @@ -194,13 +194,13 @@ - Expected values to NOT be structurally equivalent. - Expected values to NOT be structurally equivalent. + Expected values to be structurally different. + Expected values to be structurally different. - Expected values to NOT be structurally equivalent (strict mode). - Expected values to NOT be structurally equivalent (strict mode). + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). diff --git a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs index c2dd5c10ae..f619abf1a6 100644 --- a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs +++ b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs @@ -668,7 +668,7 @@ public void AreNotEquivalent_EqualValues_Fails() { Action act = () => Assert.AreNotEquivalent(new Person("Ada", 36), new Person("Ada", 36)); act.Should().Throw() - .WithMessage("*NOT be structurally equivalent*"); + .WithMessage("*structurally different*"); } public void AreNotEquivalent_BothNull_Fails() @@ -712,6 +712,37 @@ private static DeepNode CreateDeepNodeChain(int length) return root; } + public void AreEquivalent_NewShadowedProperty_UsesMostDerivedDeclaration() + { + 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_EquivalentDerivedValues_Passes() + { + var expected = new ShadowedDerived { Value = "same" }; + var actual = new ShadowedDerived { Value = "same" }; + + // The base sets BaseValueAsString = "base"; the derived `new` Value shadows it as a string. + // If the comparer ever picked the base accessor for one side, the comparison would compare + // the string "base" against the derived `Value` and fail. + Assert.AreEquivalent(expected, actual); + } + + private class ShadowedBase + { + public int Value { get; set; } + } + + private sealed class ShadowedDerived : ShadowedBase + { + public new string Value { get; set; } = "default"; + } + private sealed class Person { public Person(string name, int age) From fe26ee2a2c16879c5c5461f62cee69faa8bf8382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Sat, 16 May 2026 12:07:45 +0200 Subject: [PATCH 7/8] Address round-2 expert-review findings on AreEquivalent - TIE catches in CompareEnumerables/CompareMembers/dictionary access now use a shared ThrowIfAssertException helper that unwraps and rethrows UnitTestAssertException (e.g., Assert.Fail) when a user-supplied member getter, enumerator, or equality operator throws one through reflection. Previously such framework exceptions were silently rewritten as MemberAccessFailure / DictionaryAccessFailure / EnumerationFailure mismatches. - Replace the misleading second shadowed-member test with a same-type 'new int' shadowing fixture that actually exercises the dedup logic: if the wrong accessor is chosen, both sides resolve to the base default and the derived mismatch is silently swallowed. - Add AreEquivalent_MemberGetterCallsAssertFail_PropagatesAsAssertFailedException regression test that pins the new ThrowIfAssertException behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Assert.AreEquivalent.Comparer.cs | 22 +++++++ .../AssertTests.AreEquivalentTests.cs | 57 ++++++++++++++++--- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs index 27593a18d0..e5571e1d53 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs @@ -205,6 +205,7 @@ internal EquivalenceComparer(bool strict) } 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) @@ -228,6 +229,7 @@ internal EquivalenceComparer(bool strict) } 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) @@ -258,6 +260,7 @@ internal EquivalenceComparer(bool strict) } catch (TargetInvocationException tie) { + ThrowIfAssertException(tie.InnerException); result = default!; return EquivalenceMismatch.DictionaryAccessFailure(path, isExpected, tie.InnerException ?? tie); } @@ -388,6 +391,7 @@ internal EquivalenceComparer(bool strict) } 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) @@ -401,6 +405,7 @@ internal EquivalenceComparer(bool strict) } 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) @@ -427,6 +432,7 @@ internal EquivalenceComparer(bool strict) } catch (TargetInvocationException tie) { + ThrowIfAssertException(tie.InnerException); enumerator = default!; return EquivalenceMismatch.EnumerationFailure(path, isExpected, tie.InnerException ?? tie); } @@ -446,6 +452,7 @@ internal EquivalenceComparer(bool strict) } catch (TargetInvocationException tie) { + ThrowIfAssertException(tie.InnerException); hasNext = false; return EquivalenceMismatch.EnumerationFailure(path, isExpected, tie.InnerException ?? tie); } @@ -465,6 +472,7 @@ internal EquivalenceComparer(bool strict) } catch (TargetInvocationException tie) { + ThrowIfAssertException(tie.InnerException); current = default; return EquivalenceMismatch.EnumerationFailure(path, isExpected, tie.InnerException ?? tie); } @@ -558,6 +566,7 @@ private static IEquatableOutcome InvokeIEquatable(object expected, object actual } catch (TargetInvocationException ex) { + ThrowIfAssertException(ex.InnerException); thrown = ex.InnerException ?? ex; return IEquatableOutcome.Threw; } @@ -689,6 +698,19 @@ private static bool IsPrimitiveLike(Type type) 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. + /// + private static void ThrowIfAssertException(Exception? inner) + { + if (inner is UnitTestAssertException assertEx) + { + throw assertEx; + } + } + private static bool TryCreateDictionaryView(object value, out DictionaryView? view) { // Non-generic IDictionary takes precedence over IDictionary<,> / IReadOnlyDictionary<,>: diff --git a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs index f619abf1a6..90c6ee54ff 100644 --- a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs +++ b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs @@ -293,6 +293,20 @@ public void AreEquivalent_MemberGetterThrows_FailsWithExpectedDiagnostic() .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. + AssertFailingGetter a = new(); + AssertFailingGetter b = new(); + Action act = () => Assert.AreEquivalent(a, b); + act.Should().Throw() + .WithMessage("*nested-assert-fail*") + .And.Message.Should().NotContain("Mismatch at"); + } + public void AreEquivalent_IEquatableThrows_FailsWithExpectedDiagnostic() { ThrowingEquatable a = new(); @@ -714,6 +728,10 @@ private static DeepNode CreateDeepNodeChain(int length) 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" }; @@ -722,15 +740,18 @@ public void AreEquivalent_NewShadowedProperty_UsesMostDerivedDeclaration() .WithMessage("*Value*derived-expected*derived-actual*"); } - public void AreEquivalent_NewShadowedProperty_EquivalentDerivedValues_Passes() + public void AreEquivalent_NewShadowedProperty_SameType_DetectsDerivedMismatch() { - var expected = new ShadowedDerived { Value = "same" }; - var actual = new ShadowedDerived { Value = "same" }; + // 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 }; - // The base sets BaseValueAsString = "base"; the derived `new` Value shadows it as a string. - // If the comparer ever picked the base accessor for one side, the comparison would compare - // the string "base" against the derived `Value` and fail. - Assert.AreEquivalent(expected, actual); + Action act = () => Assert.AreEquivalent(expected, actual); + act.Should().Throw() + .WithMessage("*Value*200*999*"); } private class ShadowedBase @@ -743,6 +764,16 @@ 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) @@ -913,6 +944,18 @@ 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"); From f2417ffb55e4e5502821c4b9e72d1163bdd43e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Sat, 16 May 2026 12:25:07 +0200 Subject: [PATCH 8/8] Use ExceptionDispatchInfo to preserve stack trace when rethrowing user assertion A naive 'throw assertEx;' rewrites Exception.StackTrace with the rethrow site, hiding the user's actual Assert.Fail call inside the property getter / dictionary indexer / IEquatable.Equals. Capture-and-throw via ExceptionDispatchInfo keeps the original throw frame, which is the most actionable signal for diagnosing nested-assert failures. Extended the regression test to verify the stack trace contains the user fixture name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Assertions/Assert.AreEquivalent.Comparer.cs | 7 +++++-- .../Assertions/AssertTests.AreEquivalentTests.cs | 10 +++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs index e5571e1d53..4750108547 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.AreEquivalent.Comparer.cs @@ -1,6 +1,8 @@ // 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 @@ -702,12 +704,13 @@ private static bool IsPrimitiveLike(Type type) /// 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 assertEx) + if (inner is UnitTestAssertException) { - throw assertEx; + ExceptionDispatchInfo.Capture(inner).Throw(); } } diff --git a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs index 90c6ee54ff..ed863b3464 100644 --- a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs +++ b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.AreEquivalentTests.cs @@ -298,13 +298,17 @@ public void AreEquivalent_MemberGetterCallsAssertFail_PropagatesAsAssertFailedEx // 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. + // assertion surfaces with its original message AND original stack trace. AssertFailingGetter a = new(); AssertFailingGetter b = new(); Action act = () => Assert.AreEquivalent(a, b); - act.Should().Throw() + AssertFailedException ex = act.Should().Throw() .WithMessage("*nested-assert-fail*") - .And.Message.Should().NotContain("Mismatch at"); + .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()