@@ -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 )
0 commit comments