Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
024a2ca
feat: implement IAsyncDiscoveryInitializer and related classes for im…
thomhurst Dec 7, 2025
7cdc0fc
feat: implement IAsyncDiscoveryInitializer and related classes for im…
thomhurst Dec 7, 2025
bebb3e2
feat: implement IAsyncDiscoveryInitializer and related classes for im…
thomhurst Dec 7, 2025
0041ee5
feat: implement IAsyncDiscoveryInitializer and related classes for im…
thomhurst Dec 7, 2025
8e97f35
feat: implement IAsyncDiscoveryInitializer and related classes for im…
thomhurst Dec 7, 2025
07b2215
feat: implement IAsyncDiscoveryInitializer and related classes for im…
thomhurst Dec 7, 2025
8589baa
feat: implement IAsyncDiscoveryInitializer and related classes for im…
thomhurst Dec 7, 2025
5c78262
feat: implement IAsyncDiscoveryInitializer and related classes for im…
thomhurst Dec 7, 2025
579cd75
feat: implement IAsyncDiscoveryInitializer and related classes for im…
thomhurst Dec 7, 2025
8c661b3
feat: implement IAsyncDiscoveryInitializer and related classes for im…
thomhurst Dec 7, 2025
8030e42
feat: implement IAsyncDiscoveryInitializer and related classes for im…
thomhurst Dec 7, 2025
1646680
feat: enhance type handling and object tracking with custom primitive…
thomhurst Dec 7, 2025
851e21c
feat: improve disposal callback registration logic to handle untracke…
thomhurst Dec 7, 2025
f89adb8
feat: change visibility of object graph related classes and interface…
thomhurst Dec 7, 2025
a1938e3
feat: implement IAsyncDiscoveryInitializer and related classes for im…
thomhurst Dec 7, 2025
9efbd55
feat: skip PlaceholderInstance during data source class resolution fo…
thomhurst Dec 7, 2025
da86e46
fix: normalize line endings in exception message assertions for consi…
thomhurst Dec 7, 2025
ca3065f
fix: normalize line endings in exception messages for consistency acr…
thomhurst Dec 7, 2025
2799dd7
fix: remove premature cache removal for shared data sources
thomhurst Dec 7, 2025
ff2e57e
feat: enable parallel initialization of tracked objects during test e…
thomhurst Dec 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 2 additions & 12 deletions TUnit.Assertions/Conditions/EqualsAssertion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Reflection;
using System.Text;
using TUnit.Assertions.Attributes;
using TUnit.Assertions.Conditions.Helpers;
using TUnit.Assertions.Core;

namespace TUnit.Assertions.Conditions;
Expand Down Expand Up @@ -84,7 +85,7 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TValue> m
if (_ignoredTypes.Count > 0)
{
// Use reference-based tracking to detect cycles
var visited = new HashSet<object>(new ReferenceEqualityComparer());
var visited = new HashSet<object>(ReferenceEqualityComparer<object>.Instance);
var result = DeepEquals(value, _expected, _ignoredTypes, visited);
if (result.IsSuccess)
{
Expand Down Expand Up @@ -213,15 +214,4 @@ private static (bool IsSuccess, string? Message) DeepEquals(object? actual, obje
}

protected override string GetExpectation() => $"to be equal to {(_expected is string s ? $"\"{s}\"" : _expected)}";

/// <summary>
/// Comparer that uses reference equality instead of value equality.
/// Used for cycle detection in deep comparison.
/// </summary>
private sealed class ReferenceEqualityComparer : IEqualityComparer<object>
{
public new bool Equals(object? x, object? y) => ReferenceEquals(x, y);

public int GetHashCode(object obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj);
}
}
43 changes: 43 additions & 0 deletions TUnit.Assertions/Conditions/Helpers/ExpressionHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
namespace TUnit.Assertions.Conditions.Helpers;

/// <summary>
/// Helper methods for parsing and extracting information from assertion expressions.
/// Consolidates expression parsing logic to ensure consistent behavior across assertion classes.
/// </summary>
internal static class ExpressionHelper
{
/// <summary>
/// Extracts the source variable name from an assertion expression string.
/// </summary>
/// <param name="expression">The expression string, e.g., "Assert.That(variableName).IsEquivalentTo(...)"</param>
/// <returns>The variable name, or "value" if it cannot be extracted or is a lambda expression.</returns>
/// <example>
/// Input: "Assert.That(myObject).IsEquivalentTo(expected)"
/// Output: "myObject"
///
/// Input: "Assert.That(async () => GetValue()).IsEquivalentTo(expected)"
/// Output: "value"
/// </example>
public static string ExtractSourceVariable(string expression)
{
// Extract variable name from "Assert.That(variableName)" or similar
var thatIndex = expression.IndexOf(".That(", StringComparison.Ordinal);
if (thatIndex >= 0)
{
var startIndex = thatIndex + 6; // Length of ".That("
var endIndex = expression.IndexOf(')', startIndex);
if (endIndex > startIndex)
{
var variable = expression.Substring(startIndex, endIndex - startIndex);
// Handle lambda expressions like "async () => ..." by returning "value"
if (variable.Contains("=>") || variable.StartsWith("()", StringComparison.Ordinal))
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using StringComparison.Ordinal is good practice. However, on line 33, the second Contains call doesn't specify a comparison type. For consistency and to avoid culture-specific issues, consider using Contains("=>", StringComparison.Ordinal) if available in the target framework, or using IndexOf("=>", StringComparison.Ordinal) >= 0.

Suggested change
if (variable.Contains("=>") || variable.StartsWith("()", StringComparison.Ordinal))
if (variable.Contains("=>", StringComparison.Ordinal) || variable.StartsWith("()", StringComparison.Ordinal))

Copilot uses AI. Check for mistakes.
{
return "value";
}
return variable;
}
}

return "value";
}
}
63 changes: 63 additions & 0 deletions TUnit.Assertions/Conditions/Helpers/ReflectionHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;

namespace TUnit.Assertions.Conditions.Helpers;

/// <summary>
/// Helper methods for reflection-based member access.
/// Consolidates reflection logic to ensure consistent behavior and reduce code duplication.
/// </summary>
internal static class ReflectionHelper
{
/// <summary>
/// Gets all public instance properties and fields to compare for structural equivalency.
/// </summary>
/// <param name="type">The type to get members from.</param>
/// <returns>A list of PropertyInfo and FieldInfo members.</returns>
public static List<MemberInfo> GetMembersToCompare(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicFields)]
Type type)
{
var members = new List<MemberInfo>();
members.AddRange(type.GetProperties(BindingFlags.Public | BindingFlags.Instance));
members.AddRange(type.GetFields(BindingFlags.Public | BindingFlags.Instance));
return members;
}

/// <summary>
/// Gets the value of a member (property or field) from an object.
/// </summary>
/// <param name="obj">The object to get the value from.</param>
/// <param name="member">The member (PropertyInfo or FieldInfo) to read.</param>
/// <returns>The value of the member.</returns>
/// <exception cref="InvalidOperationException">Thrown if the member is not a PropertyInfo or FieldInfo.</exception>
public static object? GetMemberValue(object obj, MemberInfo member)
{
return member switch
{
PropertyInfo prop => prop.GetValue(obj),
FieldInfo field => field.GetValue(obj),
_ => throw new InvalidOperationException($"Unknown member type: {member.GetType()}")
};
}

/// <summary>
/// Gets a member (property or field) by name from a type.
/// </summary>
/// <param name="type">The type to search.</param>
/// <param name="name">The member name to find.</param>
/// <returns>The MemberInfo if found; null otherwise.</returns>
public static MemberInfo? GetMemberInfo(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicFields)]
Type type,
string name)
{
var property = type.GetProperty(name, BindingFlags.Public | BindingFlags.Instance);
if (property != null)
{
return property;
}

return type.GetField(name, BindingFlags.Public | BindingFlags.Instance);
}
}
54 changes: 6 additions & 48 deletions TUnit.Assertions/Conditions/Helpers/StructuralEqualityComparer.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System.Collections;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;

namespace TUnit.Assertions.Conditions.Helpers;

Expand Down Expand Up @@ -36,12 +35,12 @@ public bool Equals(T? x, T? y)

var type = typeof(T);

if (IsPrimitiveType(type))
if (TypeHelper.IsPrimitiveOrWellKnownType(type))
{
return EqualityComparer<T>.Default.Equals(x, y);
}

return CompareStructurally(x, y, new HashSet<object>(new ReferenceEqualityComparer()));
return CompareStructurally(x, y, new HashSet<object>(ReferenceEqualityComparer<object>.Instance));
}

public int GetHashCode(T obj)
Expand All @@ -54,23 +53,6 @@ public int GetHashCode(T obj)
return EqualityComparer<T>.Default.GetHashCode(obj);
}

private static bool IsPrimitiveType(Type type)
{
return type.IsPrimitive
|| type.IsEnum
|| type == typeof(string)
|| type == typeof(decimal)
|| type == typeof(DateTime)
|| type == typeof(DateTimeOffset)
|| type == typeof(TimeSpan)
|| type == typeof(Guid)
#if NET6_0_OR_GREATER
|| type == typeof(DateOnly)
|| type == typeof(TimeOnly)
#endif
;
}

[UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "GetType() is acceptable for runtime structural comparison")]
private bool CompareStructurally(object? x, object? y, HashSet<object> visited)
{
Expand All @@ -87,7 +69,7 @@ private bool CompareStructurally(object? x, object? y, HashSet<object> visited)
var xType = x.GetType();
var yType = y.GetType();

if (IsPrimitiveType(xType))
if (TypeHelper.IsPrimitiveOrWellKnownType(xType))
{
return Equals(x, y);
}
Expand Down Expand Up @@ -121,12 +103,12 @@ private bool CompareStructurally(object? x, object? y, HashSet<object> visited)
return true;
}

var members = GetMembersToCompare(xType);
var members = ReflectionHelper.GetMembersToCompare(xType);

foreach (var member in members)
{
var xValue = GetMemberValue(x, member);
var yValue = GetMemberValue(y, member);
var xValue = ReflectionHelper.GetMemberValue(x, member);
var yValue = ReflectionHelper.GetMemberValue(y, member);

if (!CompareStructurally(xValue, yValue, visited))
{
Expand All @@ -136,28 +118,4 @@ private bool CompareStructurally(object? x, object? y, HashSet<object> visited)

return true;
}

private static List<MemberInfo> GetMembersToCompare([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicFields)] Type type)
{
var members = new List<MemberInfo>();
members.AddRange(type.GetProperties(BindingFlags.Public | BindingFlags.Instance));
members.AddRange(type.GetFields(BindingFlags.Public | BindingFlags.Instance));
return members;
}

private static object? GetMemberValue(object obj, MemberInfo member)
{
return member switch
{
PropertyInfo prop => prop.GetValue(obj),
FieldInfo field => field.GetValue(obj),
_ => throw new InvalidOperationException($"Unknown member type: {member.GetType()}")
};
}

private sealed class ReferenceEqualityComparer : IEqualityComparer<object>
{
public new bool Equals(object? x, object? y) => ReferenceEquals(x, y);
public int GetHashCode(object obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj);
}
}
84 changes: 84 additions & 0 deletions TUnit.Assertions/Conditions/Helpers/TypeHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using System.Collections.Concurrent;

namespace TUnit.Assertions.Conditions.Helpers;

/// <summary>
/// Helper methods for type checking and classification.
/// Consolidates type checking logic to ensure consistent behavior across assertion classes.
/// </summary>
internal static class TypeHelper
{
/// <summary>
/// Thread-safe registry of user-defined types that should be treated as primitives
/// (using value equality rather than structural comparison).
/// </summary>
private static readonly ConcurrentDictionary<Type, byte> CustomPrimitiveTypes = new();

/// <summary>
/// Registers a type to be treated as a primitive for structural equivalency comparisons.
/// Once registered, instances of this type will use value equality (via Equals) rather
/// than having their properties compared individually.
/// </summary>
/// <typeparam name="T">The type to register as a primitive.</typeparam>
public static void RegisterAsPrimitive<T>()
{
CustomPrimitiveTypes.TryAdd(typeof(T), 0);
}

/// <summary>
/// Registers a type to be treated as a primitive for structural equivalency comparisons.
/// </summary>
/// <param name="type">The type to register as a primitive.</param>
public static void RegisterAsPrimitive(Type type)
{
CustomPrimitiveTypes.TryAdd(type, 0);
}

/// <summary>
/// Removes a previously registered custom primitive type.
/// </summary>
/// <typeparam name="T">The type to unregister.</typeparam>
/// <returns>True if the type was removed; false if it wasn't registered.</returns>
public static bool UnregisterPrimitive<T>()
{
return CustomPrimitiveTypes.TryRemove(typeof(T), out _);
}

/// <summary>
/// Clears all registered custom primitive types.
/// Useful for test cleanup between tests.
/// </summary>
public static void ClearCustomPrimitives()
{
CustomPrimitiveTypes.Clear();
}

/// <summary>
/// Determines if a type is a primitive or well-known immutable type that should use
/// value equality rather than structural comparison.
/// </summary>
/// <param name="type">The type to check.</param>
/// <returns>True if the type should use value equality; false for structural comparison.</returns>
public static bool IsPrimitiveOrWellKnownType(Type type)
{
// Check user-defined primitives first (fast path for common case)
if (CustomPrimitiveTypes.ContainsKey(type))
{
return true;
}

return type.IsPrimitive
|| type.IsEnum
|| type == typeof(string)
|| type == typeof(decimal)
|| type == typeof(DateTime)
|| type == typeof(DateTimeOffset)
|| type == typeof(TimeSpan)
|| type == typeof(Guid)
#if NET6_0_OR_GREATER
|| type == typeof(DateOnly)
|| type == typeof(TimeOnly)
#endif
;
}
}
39 changes: 8 additions & 31 deletions TUnit.Assertions/Conditions/NotStructuralEquivalencyAssertion.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using System.Text;
using TUnit.Assertions.Conditions.Helpers;
using TUnit.Assertions.Core;

namespace TUnit.Assertions.Conditions;
Expand Down Expand Up @@ -90,7 +91,12 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TValue> m
foreach (var type in _ignoredTypes)
tempAssertion.IgnoringType(type);

var result = tempAssertion.CompareObjects(value, _notExpected, "", new HashSet<object>(new ReferenceEqualityComparer()));
var result = tempAssertion.CompareObjects(
value,
_notExpected,
"",
new HashSet<object>(ReferenceEqualityComparer<object>.Instance),
new HashSet<object>(ReferenceEqualityComparer<object>.Instance));

// Invert the result - we want them to NOT be equivalent
if (result.IsPassed)
Expand All @@ -101,43 +107,14 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TValue> m
return Task.FromResult(AssertionResult.Passed);
}

private sealed class ReferenceEqualityComparer : IEqualityComparer<object>
{
public new bool Equals(object? x, object? y) => ReferenceEquals(x, y);
public int GetHashCode(object obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj);
}

protected override string GetExpectation()
{
// Extract the source variable name from the expression builder
// Format: "Assert.That(variableName).IsNotEquivalentTo(...)"
var expressionString = Context.ExpressionBuilder.ToString();
var sourceVariable = ExtractSourceVariable(expressionString);
var sourceVariable = ExpressionHelper.ExtractSourceVariable(expressionString);
var notExpectedDesc = _notExpectedExpression ?? "expected value";

return $"{sourceVariable} to not be equivalent to {notExpectedDesc}";
}

private static string ExtractSourceVariable(string expression)
{
// Extract variable name from "Assert.That(variableName)" or similar
var thatIndex = expression.IndexOf(".That(");
if (thatIndex >= 0)
{
var startIndex = thatIndex + 6; // Length of ".That("
var endIndex = expression.IndexOf(')', startIndex);
if (endIndex > startIndex)
{
var variable = expression.Substring(startIndex, endIndex - startIndex);
// Handle lambda expressions like "async () => ..." by returning "value"
if (variable.Contains("=>") || variable.StartsWith("()"))
{
return "value";
}
return variable;
}
}

return "value";
}
}
Loading
Loading