Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
60 changes: 60 additions & 0 deletions TUnit.Assertions.Tests/Bugs/Issue5702Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
namespace TUnit.Assertions.Tests.Bugs;

/// <summary>
/// Regression tests for GitHub issue #5702:
/// `.Member(x => x.StringProperty, p => p.IsEqualTo("..."))` incorrectly resolved the
/// collection `Member&lt;TObject, TItem&gt;` overload (TItem=char) instead of the general
/// `Member&lt;TObject, TMember&gt;` (TMember=string) overload because the collection
/// overload carries a higher `[OverloadResolutionPriority]`. The resulting
/// `IAssertionSource&lt;IEnumerable&lt;char&gt;&gt;` triggers TUnitAssertions0016 and
/// uses reference equality on strings, so non-interned but equal strings fail.
/// </summary>
public class Issue5702Tests
{
[Test]
public async Task Member_On_String_Property_Uses_String_Equality_Not_Reference()
{
var value = KeyValuePair.Create<string, object?>(NonInterned("error"), null);

await Assert.That(value).Member(kvp => kvp.Key, key => key.IsEqualTo(NonInterned("error")));
}

[Test]
public async Task Member_On_String_Property_With_Nested_Object()
{
var model = new Wrapper { Name = NonInterned("hello") };

await Assert.That(model).Member(m => m.Name, n => n.IsEqualTo(NonInterned("hello")));
}

[Test]
public async Task Member_On_String_Property_Composes_With_And_Chaining()
{
var model = new Wrapper { Name = NonInterned("hello"), Count = 42 };

await Assert.That(model)
.Member(m => m.Name, n => n.IsEqualTo(NonInterned("hello")))
.And.Member(m => m.Count, c => c.IsEqualTo(42));
}

[Test]
public async Task Member_On_NonString_IEnumerableChar_Still_Uses_Collection_Overload()
{
var model = new WithChars { Chars = ['h', 'i'] };

await Assert.That(model).Member(m => m.Chars, c => c.Contains('h'));
}

private static string NonInterned(string s) => new(s.ToCharArray());

private sealed class Wrapper
{
public string Name { get; set; } = string.Empty;
public int Count { get; set; }
}

private sealed class WithChars
{
public IEnumerable<char> Chars { get; set; } = [];
}
}
40 changes: 40 additions & 0 deletions TUnit.Assertions/Extensions/AssertionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,46 @@ public static MemberAssertionResult<TObject> Member<TObject, TKey, TValue>(
return BuildMemberResult(parentContext, pendingAssertion, combinerType, erasedAssertion);
}

/// <summary>
/// Asserts on a string member of an object using a lambda selector and assertion lambda.
/// This overload takes priority over the collection overload so that `string` is treated as a
/// scalar value rather than an `IEnumerable&lt;char&gt;`.
/// Example: await Assert.That(obj).Member(x => x.Name, n => n.IsEqualTo("expected"));
/// </summary>
[OverloadResolutionPriority(3)]
public static MemberAssertionResult<TObject> Member<TObject, TTransformed>(
this IAssertionSource<TObject> source,
Expression<Func<TObject, string?>> memberSelector,
Func<IAssertionSource<string>, Assertion<TTransformed>> assertions)
=> Member<TObject, string, TTransformed>(source, memberSelector, assertions);

/// <summary>
/// Asserts on a string member of an object using a lambda selector and assertion lambda.
/// This overload takes priority over the collection overload so that `string` is treated as a
/// scalar value rather than an `IEnumerable&lt;char&gt;`.
/// </summary>
[OverloadResolutionPriority(3)]
public static MemberAssertionResult<TObject> Member<TObject>(
this IAssertionSource<TObject> source,
Expression<Func<TObject, string?>> memberSelector,
Func<IAssertionSource<string>, Assertion<string>> assertions)
=> Member<TObject, string>(source, memberSelector, assertions);

/// <summary>
/// Asserts on a string member of an object using a lambda selector and assertion lambda
/// that returns an untyped assertion (for extension methods returning non-<see cref="Assertion{T}"/> types).
/// This overload takes priority over the collection overload so that `string` is treated as a
/// scalar value rather than an `IEnumerable&lt;char&gt;`.
/// Note: For AOT compatibility, use the TTransformed overload instead.
/// </summary>
[OverloadResolutionPriority(3)]
[RequiresDynamicCode("Uses reflection for legacy compatibility. For AOT compatibility, use the Member<TObject, TTransformed> overload with strongly-typed assertions.")]
public static MemberAssertionResult<TObject> Member<TObject>(
this IAssertionSource<TObject> source,
Expression<Func<TObject, string?>> memberSelector,
Func<IAssertionSource<string>, object> assertions)
=> Member<TObject, string>(source, memberSelector, assertions);

/// <summary>
/// Asserts on a collection member of an object using a lambda selector and assertion lambda.
/// The assertion lambda receives collection assertion methods (Count, Contains, IsEmpty, etc.).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2693,6 +2693,12 @@ namespace .Extensions
where TValue : struct, <TValue> { }
public static . Length(this .<string> source) { }
public static . Length(this .<string> source, <.<int>, .<int>?> lengthAssertion, [.("lengthAssertion")] string? expression = null) { }
[.(3)]
public static .<TObject> Member<TObject>(this .<TObject> source, .<<TObject, string?>> memberSelector, <.<string>, .<string>> assertions) { }
[.("Uses reflection for legacy compatibility. For AOT compatibility, use the Member<T" +
"Object, TTransformed> overload with strongly-typed assertions.")]
[.(3)]
public static .<TObject> Member<TObject>(this .<TObject> source, .<<TObject, string?>> memberSelector, <.<string>, object> assertions) { }
[.(1)]
public static .<TObject> Member<TObject, TItem>(this .<TObject> source, .<<TObject, .<TItem>>> memberSelector, <.<.<TItem>, TItem>, .<.<TItem>>> assertions) { }
[.("Uses reflection for legacy compatibility. For AOT compatibility, use the Member<T" +
Expand All @@ -2713,6 +2719,8 @@ namespace .Extensions
[.("Uses reflection for legacy compatibility. For AOT compatibility, use the Member<T" +
"Object, TMember, TTransformed> overload with strongly-typed assertions.")]
public static .<TObject> Member<TObject, TMember>(this .<TObject> source, .<<TObject, TMember?>> memberSelector, <.<TMember>, object> assertions) { }
[.(3)]
public static .<TObject> Member<TObject, TTransformed>(this .<TObject> source, .<<TObject, string?>> memberSelector, <.<string>, .<TTransformed>> assertions) { }
[.(2)]
public static .<TObject> Member<TObject, TItem, TTransformed>(this .<TObject> source, .<<TObject, .<TItem>>> memberSelector, <.<.<TItem>, TItem>, .<TTransformed>> assertions) { }
[.(2)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2672,6 +2672,10 @@ namespace .Extensions
where TValue : struct, <TValue> { }
public static . Length(this .<string> source) { }
public static . Length(this .<string> source, <.<int>, .<int>?> lengthAssertion, [.("lengthAssertion")] string? expression = null) { }
public static .<TObject> Member<TObject>(this .<TObject> source, .<<TObject, string?>> memberSelector, <.<string>, .<string>> assertions) { }
[.("Uses reflection for legacy compatibility. For AOT compatibility, use the Member<T" +
"Object, TTransformed> overload with strongly-typed assertions.")]
public static .<TObject> Member<TObject>(this .<TObject> source, .<<TObject, string?>> memberSelector, <.<string>, object> assertions) { }
public static .<TObject> Member<TObject, TItem>(this .<TObject> source, .<<TObject, .<TItem>>> memberSelector, <.<.<TItem>, TItem>, .<.<TItem>>> assertions) { }
[.("Uses reflection for legacy compatibility. For AOT compatibility, use the Member<T" +
"Object, TItem, TTransformed> overload with strongly-typed assertions.")]
Expand All @@ -2688,6 +2692,7 @@ namespace .Extensions
[.("Uses reflection for legacy compatibility. For AOT compatibility, use the Member<T" +
"Object, TMember, TTransformed> overload with strongly-typed assertions.")]
public static .<TObject> Member<TObject, TMember>(this .<TObject> source, .<<TObject, TMember?>> memberSelector, <.<TMember>, object> assertions) { }
public static .<TObject> Member<TObject, TTransformed>(this .<TObject> source, .<<TObject, string?>> memberSelector, <.<string>, .<TTransformed>> assertions) { }
public static .<TObject> Member<TObject, TItem, TTransformed>(this .<TObject> source, .<<TObject, .<TItem>>> memberSelector, <.<.<TItem>, TItem>, .<TTransformed>> assertions) { }
public static .<TObject> Member<TObject, TKey, TValue>(this .<TObject> source, .<<TObject, .<TKey, TValue>>> memberSelector, <.<.<TKey, TValue>, TKey, TValue>, .<.<TKey, TValue>>> assertions)
where TKey : notnull { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2693,6 +2693,12 @@ namespace .Extensions
where TValue : struct, <TValue> { }
public static . Length(this .<string> source) { }
public static . Length(this .<string> source, <.<int>, .<int>?> lengthAssertion, [.("lengthAssertion")] string? expression = null) { }
[.(3)]
public static .<TObject> Member<TObject>(this .<TObject> source, .<<TObject, string?>> memberSelector, <.<string>, .<string>> assertions) { }
[.("Uses reflection for legacy compatibility. For AOT compatibility, use the Member<T" +
"Object, TTransformed> overload with strongly-typed assertions.")]
[.(3)]
public static .<TObject> Member<TObject>(this .<TObject> source, .<<TObject, string?>> memberSelector, <.<string>, object> assertions) { }
[.(1)]
public static .<TObject> Member<TObject, TItem>(this .<TObject> source, .<<TObject, .<TItem>>> memberSelector, <.<.<TItem>, TItem>, .<.<TItem>>> assertions) { }
[.("Uses reflection for legacy compatibility. For AOT compatibility, use the Member<T" +
Expand All @@ -2713,6 +2719,8 @@ namespace .Extensions
[.("Uses reflection for legacy compatibility. For AOT compatibility, use the Member<T" +
"Object, TMember, TTransformed> overload with strongly-typed assertions.")]
public static .<TObject> Member<TObject, TMember>(this .<TObject> source, .<<TObject, TMember?>> memberSelector, <.<TMember>, object> assertions) { }
[.(3)]
public static .<TObject> Member<TObject, TTransformed>(this .<TObject> source, .<<TObject, string?>> memberSelector, <.<string>, .<TTransformed>> assertions) { }
[.(2)]
public static .<TObject> Member<TObject, TItem, TTransformed>(this .<TObject> source, .<<TObject, .<TItem>>> memberSelector, <.<.<TItem>, TItem>, .<TTransformed>> assertions) { }
[.(2)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2418,6 +2418,8 @@ namespace .Extensions
where TValue : struct, <TValue> { }
public static . Length(this .<string> source) { }
public static . Length(this .<string> source, <.<int>, .<int>?> lengthAssertion, [.("lengthAssertion")] string? expression = null) { }
public static .<TObject> Member<TObject>(this .<TObject> source, .<<TObject, string?>> memberSelector, <.<string>, .<string>> assertions) { }
public static .<TObject> Member<TObject>(this .<TObject> source, .<<TObject, string?>> memberSelector, <.<string>, object> assertions) { }
public static .<TObject> Member<TObject, TItem>(this .<TObject> source, .<<TObject, .<TItem>>> memberSelector, <.<.<TItem>, TItem>, .<.<TItem>>> assertions) { }
public static .<TObject> Member<TObject, TItem>(this .<TObject> source, .<<TObject, .<TItem>>> memberSelector, <.<.<TItem>, TItem>, object> assertions) { }
public static .<TObject> Member<TObject, TMember>(this .<TObject> source, .<<TObject, .<TMember>>> memberSelector, <.<TMember>, .<TMember>> assertions) { }
Expand All @@ -2426,6 +2428,7 @@ namespace .Extensions
public static .<TObject> Member<TObject, TMember>(this .<TObject> source, .<<TObject, .<TMember>>> memberSelector, <.<TMember>, object> assertions) { }
public static .<TObject> Member<TObject, TMember>(this .<TObject> source, .<<TObject, TMember?>> memberSelector, <.<TMember>, .<TMember>> assertions) { }
public static .<TObject> Member<TObject, TMember>(this .<TObject> source, .<<TObject, TMember?>> memberSelector, <.<TMember>, object> assertions) { }
public static .<TObject> Member<TObject, TTransformed>(this .<TObject> source, .<<TObject, string?>> memberSelector, <.<string>, .<TTransformed>> assertions) { }
public static .<TObject> Member<TObject, TItem, TTransformed>(this .<TObject> source, .<<TObject, .<TItem>>> memberSelector, <.<.<TItem>, TItem>, .<TTransformed>> assertions) { }
public static .<TObject> Member<TObject, TKey, TValue>(this .<TObject> source, .<<TObject, .<TKey, TValue>>> memberSelector, <.<.<TKey, TValue>, TKey, TValue>, .<.<TKey, TValue>>> assertions)
where TKey : notnull { }
Expand Down
Loading