Skip to content

Commit eb0b786

Browse files
authored
Add more assertion Member overloads for better type inference (#3518)
1 parent 00bd48f commit eb0b786

5 files changed

+241
-8
lines changed

TUnit.Assertions/Extensions/AssertionExtensions.cs

Lines changed: 205 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,60 @@ public static IsTypeOfRuntimeAssertion<TValue> IsOfType<TValue>(
165165
return new IsTypeOfRuntimeAssertion<TValue>(source.Context, expectedType);
166166
}
167167

168+
/// <summary>
169+
/// Asserts on a dictionary member of an object using a lambda selector and assertion lambda.
170+
/// The assertion lambda receives dictionary assertion methods (ContainsKey, ContainsValue, IsEmpty, etc.).
171+
/// Supports type transformations like IsTypeOf within the assertion lambda.
172+
/// After the member assertion completes, returns to the parent object context for further chaining.
173+
/// Example: await Assert.That(myObject).Member(x => x.Attributes, attrs => attrs.ContainsKey("status").And.IsNotEmpty());
174+
/// </summary>
175+
[OverloadResolutionPriority(3)]
176+
public static MemberAssertionResult<TObject> Member<TObject, TKey, TValue, TTransformed>(
177+
this IAssertionSource<TObject> source,
178+
Expression<Func<TObject, IReadOnlyDictionary<TKey, TValue>>> memberSelector,
179+
Func<DictionaryMemberAssertionAdapter<IReadOnlyDictionary<TKey, TValue>, TKey, TValue>, Assertion<TTransformed>> assertions)
180+
{
181+
var parentContext = source.Context;
182+
var memberPath = GetMemberPath(memberSelector);
183+
184+
parentContext.ExpressionBuilder.Append($".Member(x => x.{memberPath}, ...)");
185+
186+
// Check if there's a pending link (from .And or .Or) that needs to be consumed
187+
var (pendingAssertion, combinerType) = parentContext.ConsumePendingLink();
188+
189+
// Map to member context
190+
var memberContext = parentContext.Map<IReadOnlyDictionary<TKey, TValue>>(obj =>
191+
{
192+
if (obj == null)
193+
{
194+
throw new InvalidOperationException($"Object `{typeof(TObject).Name}` was null");
195+
}
196+
197+
var compiled = memberSelector.Compile();
198+
return compiled(obj);
199+
});
200+
201+
// Create a DictionaryMemberAssertionAdapter for the member
202+
var dictionaryAdapter = new DictionaryMemberAssertionAdapter<IReadOnlyDictionary<TKey, TValue>, TKey, TValue>(memberContext);
203+
var memberAssertion = assertions(dictionaryAdapter);
204+
205+
// Type-erase to object? for storage - using TTransformed instead of dictionary type
206+
var erasedAssertion = new TypeErasedAssertion<TTransformed>(memberAssertion);
207+
208+
// If there was a pending link, wrap both assertions together
209+
if (pendingAssertion != null && combinerType != null)
210+
{
211+
// Create a combined wrapper that executes the pending assertion first (or together for Or)
212+
Assertion<object?> combinedAssertion = combinerType == CombinerType.And
213+
? new CombinedAndAssertion<TObject>(parentContext, pendingAssertion, erasedAssertion)
214+
: new CombinedOrAssertion<TObject>(parentContext, pendingAssertion, erasedAssertion);
215+
216+
return new MemberAssertionResult<TObject>(parentContext, combinedAssertion);
217+
}
218+
219+
return new MemberAssertionResult<TObject>(parentContext, erasedAssertion);
220+
}
221+
168222
/// <summary>
169223
/// Asserts on a dictionary member of an object using a lambda selector and assertion lambda.
170224
/// The assertion lambda receives dictionary assertion methods (ContainsKey, ContainsValue, IsEmpty, etc.).
@@ -223,8 +277,10 @@ public static MemberAssertionResult<TObject> Member<TObject, TKey, TValue>(
223277
/// The assertion lambda receives dictionary assertion methods (ContainsKey, ContainsValue, IsEmpty, etc.).
224278
/// After the member assertion completes, returns to the parent object context for further chaining.
225279
/// Example: await Assert.That(myObject).Member(x => x.Attributes, attrs => attrs.ContainsKey("status").And.IsNotEmpty());
280+
/// Note: This overload exists for backward compatibility. For AOT compatibility, use the TTransformed overload instead.
226281
/// </summary>
227282
[OverloadResolutionPriority(2)]
283+
[RequiresDynamicCode("Uses reflection for legacy compatibility. For AOT compatibility, use the Member<TObject, TKey, TValue, TTransformed> overload with strongly-typed assertions.")]
228284
public static MemberAssertionResult<TObject> Member<TObject, TKey, TValue>(
229285
this IAssertionSource<TObject> source,
230286
Expression<Func<TObject, IReadOnlyDictionary<TKey, TValue>>> memberSelector,
@@ -255,7 +311,7 @@ public static MemberAssertionResult<TObject> Member<TObject, TKey, TValue>(
255311
var memberAssertionObj = assertions(dictionaryAdapter);
256312

257313
// Type-erase to object? for storage
258-
var erasedAssertion = WrapMemberAssertion<IReadOnlyDictionary<TKey, TValue>>(memberAssertionObj);
314+
var erasedAssertion = WrapMemberAssertion(memberAssertionObj);
259315

260316
// If there was a pending link, wrap both assertions together
261317
if (pendingAssertion != null && combinerType != null)
@@ -270,6 +326,60 @@ public static MemberAssertionResult<TObject> Member<TObject, TKey, TValue>(
270326
return new MemberAssertionResult<TObject>(parentContext, erasedAssertion);
271327
}
272328

329+
/// <summary>
330+
/// Asserts on a collection member of an object using a lambda selector and assertion lambda.
331+
/// The assertion lambda receives collection assertion methods (HasCount, Contains, IsEmpty, etc.).
332+
/// Supports type transformations like IsTypeOf within the assertion lambda.
333+
/// After the member assertion completes, returns to the parent object context for further chaining.
334+
/// Example: await Assert.That(myObject).Member(x => x.Tags, tags => tags.HasCount(1).And.Contains("value"));
335+
/// </summary>
336+
[OverloadResolutionPriority(2)]
337+
public static MemberAssertionResult<TObject> Member<TObject, TItem, TTransformed>(
338+
this IAssertionSource<TObject> source,
339+
Expression<Func<TObject, IEnumerable<TItem>>> memberSelector,
340+
Func<CollectionMemberAssertionAdapter<IEnumerable<TItem>, TItem>, Assertion<TTransformed>> assertions)
341+
{
342+
var parentContext = source.Context;
343+
var memberPath = GetMemberPath(memberSelector);
344+
345+
parentContext.ExpressionBuilder.Append($".Member(x => x.{memberPath}, ...)");
346+
347+
// Check if there's a pending link (from .And or .Or) that needs to be consumed
348+
var (pendingAssertion, combinerType) = parentContext.ConsumePendingLink();
349+
350+
// Map to member context
351+
var memberContext = parentContext.Map<IEnumerable<TItem>>(obj =>
352+
{
353+
if (obj == null)
354+
{
355+
throw new InvalidOperationException($"Object `{typeof(TObject).Name}` was null");
356+
}
357+
358+
var compiled = memberSelector.Compile();
359+
return compiled(obj);
360+
});
361+
362+
// Create a CollectionMemberAssertionAdapter for the member
363+
var collectionAdapter = new CollectionMemberAssertionAdapter<IEnumerable<TItem>, TItem>(memberContext);
364+
var memberAssertion = assertions(collectionAdapter);
365+
366+
// Type-erase to object? for storage - using TTransformed instead of collection type
367+
var erasedAssertion = new TypeErasedAssertion<TTransformed>(memberAssertion);
368+
369+
// If there was a pending link, wrap both assertions together
370+
if (pendingAssertion != null && combinerType != null)
371+
{
372+
// Create a combined wrapper that executes the pending assertion first (or together for Or)
373+
Assertion<object?> combinedAssertion = combinerType == CombinerType.And
374+
? new CombinedAndAssertion<TObject>(parentContext, pendingAssertion, erasedAssertion)
375+
: new CombinedOrAssertion<TObject>(parentContext, pendingAssertion, erasedAssertion);
376+
377+
return new MemberAssertionResult<TObject>(parentContext, combinedAssertion);
378+
}
379+
380+
return new MemberAssertionResult<TObject>(parentContext, erasedAssertion);
381+
}
382+
273383
/// <summary>
274384
/// Asserts on a collection member of an object using a lambda selector and assertion lambda.
275385
/// The assertion lambda receives collection assertion methods (HasCount, Contains, IsEmpty, etc.).
@@ -328,8 +438,10 @@ public static MemberAssertionResult<TObject> Member<TObject, TItem>(
328438
/// The assertion lambda receives collection assertion methods (HasCount, Contains, IsEmpty, etc.).
329439
/// After the member assertion completes, returns to the parent object context for further chaining.
330440
/// Example: await Assert.That(myObject).Member(x => x.Tags, tags => tags.HasCount(1).And.Contains("value"));
441+
/// Note: This overload exists for backward compatibility. For AOT compatibility, use the TTransformed overload instead.
331442
/// </summary>
332443
[OverloadResolutionPriority(1)]
444+
[RequiresDynamicCode("Uses reflection for legacy compatibility. For AOT compatibility, use the Member<TObject, TItem, TTransformed> overload with strongly-typed assertions.")]
333445
public static MemberAssertionResult<TObject> Member<TObject, TItem>(
334446
this IAssertionSource<TObject> source,
335447
Expression<Func<TObject, IEnumerable<TItem>>> memberSelector,
@@ -360,7 +472,7 @@ public static MemberAssertionResult<TObject> Member<TObject, TItem>(
360472
var memberAssertionObj = assertions(collectionAdapter);
361473

362474
// Type-erase to object? for storage
363-
var erasedAssertion = WrapMemberAssertion<IEnumerable<TItem>>(memberAssertionObj);
475+
var erasedAssertion = WrapMemberAssertion(memberAssertionObj);
364476

365477
// If there was a pending link, wrap both assertions together
366478
if (pendingAssertion != null && combinerType != null)
@@ -375,6 +487,60 @@ public static MemberAssertionResult<TObject> Member<TObject, TItem>(
375487
return new MemberAssertionResult<TObject>(parentContext, erasedAssertion);
376488
}
377489

490+
/// <summary>
491+
/// Asserts on a member of an object using a lambda selector and assertion lambda.
492+
/// The assertion lambda receives the member value and can perform any assertions on it.
493+
/// Supports type transformations like IsTypeOf within the assertion lambda.
494+
/// After the member assertion completes, returns to the parent object context for further chaining.
495+
/// Example: await Assert.That(myObject).Member(x => x.PropertyName, value => value.IsTypeOf<string>().And.IsEqualTo(expectedValue));
496+
/// </summary>
497+
[OverloadResolutionPriority(1)]
498+
public static MemberAssertionResult<TObject> Member<TObject, TMember, TTransformed>(
499+
this IAssertionSource<TObject> source,
500+
Expression<Func<TObject, TMember>> memberSelector,
501+
Func<IAssertionSource<TMember>, Assertion<TTransformed>> assertions)
502+
{
503+
var parentContext = source.Context;
504+
var memberPath = GetMemberPath(memberSelector);
505+
506+
parentContext.ExpressionBuilder.Append($".Member(x => x.{memberPath}, ...)");
507+
508+
// Check if there's a pending link (from .And or .Or) that needs to be consumed
509+
var (pendingAssertion, combinerType) = parentContext.ConsumePendingLink();
510+
511+
// Map to member context
512+
var memberContext = parentContext.Map<TMember>(obj =>
513+
{
514+
if (obj == null)
515+
{
516+
throw new InvalidOperationException($"Object `{typeof(TObject).Name}` was null");
517+
}
518+
519+
var compiled = memberSelector.Compile();
520+
return compiled(obj);
521+
});
522+
523+
// Let user build assertion via lambda
524+
var memberSource = new AssertionSourceAdapter<TMember>(memberContext);
525+
var memberAssertion = assertions(memberSource);
526+
527+
// Type-erase to object? for storage - using TTransformed instead of member type
528+
var erasedAssertion = new TypeErasedAssertion<TTransformed>(memberAssertion);
529+
530+
// If there was a pending link, wrap both assertions together
531+
if (pendingAssertion != null && combinerType != null)
532+
{
533+
// Create a combined wrapper that executes the pending assertion first (or together for Or)
534+
Assertion<object?> combinedAssertion = combinerType == CombinerType.And
535+
? new CombinedAndAssertion<TObject>(parentContext, pendingAssertion, erasedAssertion)
536+
: new CombinedOrAssertion<TObject>(parentContext, pendingAssertion, erasedAssertion);
537+
538+
return new MemberAssertionResult<TObject>(parentContext, combinedAssertion);
539+
}
540+
541+
return new MemberAssertionResult<TObject>(parentContext, erasedAssertion);
542+
}
543+
378544
/// <summary>
379545
/// Asserts on a member of an object using a lambda selector and assertion lambda.
380546
/// The assertion lambda receives the member value and can perform any assertions on it.
@@ -432,7 +598,9 @@ public static MemberAssertionResult<TObject> Member<TObject, TMember>(
432598
/// The assertion lambda receives the member value and can perform any assertions on it.
433599
/// After the member assertion completes, returns to the parent object context for further chaining.
434600
/// Example: await Assert.That(myObject).Member(x => x.PropertyName, value => value.IsEqualTo(expectedValue));
601+
/// Note: This overload exists for backward compatibility. For AOT compatibility, use the TTransformed overload instead.
435602
/// </summary>
603+
[RequiresDynamicCode("Uses reflection for legacy compatibility. For AOT compatibility, use the Member<TObject, TMember, TTransformed> overload with strongly-typed assertions.")]
436604
public static MemberAssertionResult<TObject> Member<TObject, TMember>(
437605
this IAssertionSource<TObject> source,
438606
Expression<Func<TObject, TMember>> memberSelector,
@@ -463,7 +631,7 @@ public static MemberAssertionResult<TObject> Member<TObject, TMember>(
463631
var memberAssertionObj = assertions(memberSource);
464632

465633
// Type-erase to object? for storage
466-
var erasedAssertion = WrapMemberAssertion<TMember>(memberAssertionObj);
634+
var erasedAssertion = WrapMemberAssertion(memberAssertionObj);
467635

468636
// If there was a pending link, wrap both assertions together
469637
if (pendingAssertion != null && combinerType != null)
@@ -480,17 +648,46 @@ public static MemberAssertionResult<TObject> Member<TObject, TMember>(
480648

481649
/// <summary>
482650
/// Helper method to wrap member assertions for type erasure.
651+
/// Uses reflection to handle assertions of any type, including type-transformed assertions.
652+
/// Note: This fallback path uses reflection for legacy object-based overloads.
653+
/// New code should use the TTransformed overloads which are AOT-compatible.
483654
/// </summary>
484-
private static Assertion<object?> WrapMemberAssertion<TMember>(object memberAssertion)
655+
[RequiresDynamicCode("Uses reflection to dynamically construct TypeErasedAssertion<T>. For AOT compatibility, use the strongly-typed TTransformed overloads instead of object-returning lambdas.")]
656+
private static Assertion<object?> WrapMemberAssertion(object memberAssertion)
485657
{
486-
if (memberAssertion is Assertion<TMember> standardAssertion)
658+
if (memberAssertion is null)
487659
{
488-
return new TypeErasedAssertion<TMember>(standardAssertion);
660+
throw new InvalidOperationException("Member assertion cannot be null.");
661+
}
662+
663+
var type = memberAssertion.GetType();
664+
665+
// Walk up the inheritance chain to find the Assertion<T> base class
666+
Type? assertionBaseType = null;
667+
var currentType = type;
668+
while (currentType != null && currentType != typeof(object))
669+
{
670+
if (currentType.IsGenericType && currentType.GetGenericTypeDefinition() == typeof(Assertion<>))
671+
{
672+
assertionBaseType = currentType;
673+
break;
674+
}
675+
currentType = currentType.BaseType;
676+
}
677+
678+
if (assertionBaseType != null)
679+
{
680+
// Extract the generic type parameter from Assertion<T>
681+
var memberType = assertionBaseType.GetGenericArguments()[0];
682+
683+
// Create TypeErasedAssertion<T> dynamically using the discovered type
684+
var typeErasedAssertionType = typeof(TypeErasedAssertion<>).MakeGenericType(memberType);
685+
return (Assertion<object?>)Activator.CreateInstance(typeErasedAssertionType, memberAssertion)!;
489686
}
490687

491688
throw new InvalidOperationException(
492-
$"Member assertion returned unexpected type: {memberAssertion.GetType()}. " +
493-
$"Expected Assertion<{typeof(TMember).Name}>.");
689+
$"Member assertion returned unexpected type: {type.Name}. " +
690+
"Expected a type inheriting from Assertion<T>.");
494691
}
495692

496693
private static string GetMemberPath<TObject, TMember>(Expression<Func<TObject, TMember>> expression)

TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1902,14 +1902,26 @@ namespace .Extensions
19021902
where TValue : struct, <TValue> { }
19031903
[.(1)]
19041904
public static .<TObject> Member<TObject, TItem>(this .<TObject> source, .<<TObject, .<TItem>>> memberSelector, <.<.<TItem>, TItem>, .<.<TItem>>> assertions) { }
1905+
[.("Uses reflection for legacy compatibility. For AOT compatibility, use the Member<T" +
1906+
"Object, TItem, TTransformed> overload with strongly-typed assertions.")]
19051907
[.(1)]
19061908
public static .<TObject> Member<TObject, TItem>(this .<TObject> source, .<<TObject, .<TItem>>> memberSelector, <.<.<TItem>, TItem>, object> assertions) { }
19071909
public static .<TObject> Member<TObject, TMember>(this .<TObject> source, .<<TObject, TMember>> memberSelector, <.<TMember>, .<TMember>> assertions) { }
1910+
[.("Uses reflection for legacy compatibility. For AOT compatibility, use the Member<T" +
1911+
"Object, TMember, TTransformed> overload with strongly-typed assertions.")]
19081912
public static .<TObject> Member<TObject, TMember>(this .<TObject> source, .<<TObject, TMember>> memberSelector, <.<TMember>, object> assertions) { }
19091913
[.(2)]
1914+
public static .<TObject> Member<TObject, TItem, TTransformed>(this .<TObject> source, .<<TObject, .<TItem>>> memberSelector, <.<.<TItem>, TItem>, .<TTransformed>> assertions) { }
1915+
[.(2)]
19101916
public static .<TObject> Member<TObject, TKey, TValue>(this .<TObject> source, .<<TObject, .<TKey, TValue>>> memberSelector, <.<.<TKey, TValue>, TKey, TValue>, .<.<TKey, TValue>>> assertions) { }
1917+
[.("Uses reflection for legacy compatibility. For AOT compatibility, use the Member<T" +
1918+
"Object, TKey, TValue, TTransformed> overload with strongly-typed assertions.")]
19111919
[.(2)]
19121920
public static .<TObject> Member<TObject, TKey, TValue>(this .<TObject> source, .<<TObject, .<TKey, TValue>>> memberSelector, <.<.<TKey, TValue>, TKey, TValue>, object> assertions) { }
1921+
[.(1)]
1922+
public static .<TObject> Member<TObject, TMember, TTransformed>(this .<TObject> source, .<<TObject, TMember>> memberSelector, <.<TMember>, .<TTransformed>> assertions) { }
1923+
[.(3)]
1924+
public static .<TObject> Member<TObject, TKey, TValue, TTransformed>(this .<TObject> source, .<<TObject, .<TKey, TValue>>> memberSelector, <.<.<TKey, TValue>, TKey, TValue>, .<TTransformed>> assertions) { }
19131925
public static .<TValue> Satisfies<TValue>(this .<TValue> source, <TValue?, bool> predicate, [.("predicate")] string? expression = null) { }
19141926
public static .<TValue, TMapped> Satisfies<TValue, TMapped>(this .<TValue> source, <TValue?, .<TMapped>> selector, <.<TMapped>, .<TMapped>?> assertions, [.("selector")] string? selectorExpression = null) { }
19151927
public static .<TValue, TMapped> Satisfies<TValue, TMapped>(this .<TValue> source, <TValue?, TMapped> selector, <.<TMapped>, .<TMapped>?> assertions, [.("selector")] string? selectorExpression = null) { }

0 commit comments

Comments
 (0)