diff --git a/TUnit.Assertions.Tests/Bugs/Issue5706Tests.cs b/TUnit.Assertions.Tests/Bugs/Issue5706Tests.cs
new file mode 100644
index 0000000000..10dca85cc3
--- /dev/null
+++ b/TUnit.Assertions.Tests/Bugs/Issue5706Tests.cs
@@ -0,0 +1,188 @@
+namespace TUnit.Assertions.Tests.Bugs;
+
+///
+/// Regression tests for GitHub issue #5706:
+/// ItemAt(...).Satisfies(...) should preserve specialised assertion sources for
+/// collection-like item values instead of exposing only IAssertionSource<TItem>.
+///
+public class Issue5706Tests
+{
+ [Test]
+ public async Task List_ItemAt_Satisfies_Preserves_String_Item_Source()
+ {
+ IList items = new List { "alpha" };
+
+ await Assert.That(items).ItemAt(0).Satisfies(item => item.Contains("pha"));
+ }
+
+ [Test]
+ public async Task List_ItemAt_Satisfies_Preserves_Collection_Item_Source()
+ {
+ IList> items = new List> { new[] { 1, 2, 3 } };
+
+ await Assert.That(items).ItemAt(0).Satisfies(item => item.Count().IsEqualTo(3));
+ }
+
+ [Test]
+ public async Task List_ItemAt_Satisfies_Preserves_IList_Item_Source()
+ {
+ IList> items = new List> { new List { 1, 2, 3 } };
+
+ await Assert.That(items).ItemAt(0).Satisfies(item => item.Count().IsEqualTo(3));
+ }
+
+ [Test]
+ public async Task List_ItemAt_Satisfies_Preserves_IReadOnlyList_Item_Source()
+ {
+ IList> items = new List> { new List { 1, 2, 3 } };
+
+ await Assert.That(items).ItemAt(0).Satisfies(item => item.Count().IsEqualTo(3));
+ }
+
+ [Test]
+ public async Task List_ItemAt_Satisfies_Preserves_Array_Item_Source()
+ {
+ IList items = new List { new[] { 1, 2, 3 } };
+
+ await Assert.That(items).ItemAt(0).Satisfies(item => item.Count().IsEqualTo(3));
+ }
+
+ [Test]
+ public async Task List_ItemAt_Satisfies_Preserves_ConcreteList_Item_Source()
+ {
+ IList> items = new List> { new() { 1, 2, 3 } };
+
+ await Assert.That(items).ItemAt(0).Satisfies(item => item.Count().IsEqualTo(3));
+ }
+
+ [Test]
+ public async Task List_ItemAt_Satisfies_Preserves_Dictionary_Item_Source()
+ {
+ IList> items = new List>
+ {
+ new Dictionary { ["answer"] = 42 }
+ };
+
+ await Assert.That(items).ItemAt(0).Satisfies(item => item.ContainsKey("answer"));
+ }
+
+ [Test]
+ public async Task List_ItemAt_Satisfies_Preserves_Concrete_Dictionary_Item_Source()
+ {
+ IList> items = new List>
+ {
+ new() { ["answer"] = 42 }
+ };
+
+ await Assert.That(items).ItemAt(0).Satisfies(item => item.ContainsKey("answer"));
+ }
+
+ [Test]
+ public async Task List_ItemAt_Satisfies_Preserves_Set_Item_Source()
+ {
+ IList> items = new List>
+ {
+ new HashSet { 1, 2, 3 }
+ };
+
+ await Assert.That(items).ItemAt(0).Satisfies(item => item.IsSupersetOf(new[] { 1, 2 }));
+ }
+
+ [Test]
+ public async Task List_ItemAt_Satisfies_Preserves_HashSet_Item_Source()
+ {
+ IList> items = new List>
+ {
+ new() { 1, 2, 3 }
+ };
+
+ await Assert.That(items).ItemAt(0).Satisfies(item => item.IsSupersetOf(new[] { 1, 2 }));
+ }
+
+ [Test]
+ public async Task ReadOnlyList_ItemAt_Satisfies_Preserves_String_Item_Source()
+ {
+ IReadOnlyList items = new List { "alpha" };
+
+ await Assert.That(items).ItemAt(0).Satisfies(item => item.Contains("pha"));
+ }
+
+ [Test]
+ public async Task ReadOnlyList_ItemAt_Satisfies_Preserves_Collection_Item_Source()
+ {
+ IReadOnlyList> items = new List>
+ {
+ new() { 1, 2, 3 }
+ };
+
+ await Assert.That(items).ItemAt(0).Satisfies(item => item.Count().IsEqualTo(3));
+ }
+
+ [Test]
+ public async Task ReadOnlyList_ItemAt_Satisfies_Preserves_IList_Item_Source()
+ {
+ IReadOnlyList> items = new List> { new List { 1, 2, 3 } };
+
+ await Assert.That(items).ItemAt(0).Satisfies(item => item.Count().IsEqualTo(3));
+ }
+
+ [Test]
+ public async Task ReadOnlyList_ItemAt_Satisfies_Preserves_IReadOnlyList_Item_Source()
+ {
+ IReadOnlyList> items = new List> { new List { 1, 2, 3 } };
+
+ await Assert.That(items).ItemAt(0).Satisfies(item => item.Count().IsEqualTo(3));
+ }
+
+ [Test]
+ public async Task ReadOnlyList_ItemAt_Satisfies_Preserves_Array_Item_Source()
+ {
+ IReadOnlyList items = new List { new[] { 1, 2, 3 } };
+
+ await Assert.That(items).ItemAt(0).Satisfies(item => item.Count().IsEqualTo(3));
+ }
+
+ [Test]
+ public async Task ReadOnlyList_ItemAt_Satisfies_Preserves_Dictionary_Item_Source()
+ {
+ IReadOnlyList> items = new List>
+ {
+ new() { ["answer"] = 42 }
+ };
+
+ await Assert.That(items).ItemAt(0).Satisfies(item => item.ContainsKey("answer"));
+ }
+
+ [Test]
+ public async Task ReadOnlyList_ItemAt_Satisfies_Preserves_ReadOnly_Dictionary_Item_Source()
+ {
+ IReadOnlyList> items = new List>
+ {
+ new Dictionary { ["answer"] = 42 }
+ };
+
+ await Assert.That(items).ItemAt(0).Satisfies(item => item.ContainsKey("answer"));
+ }
+
+ [Test]
+ public async Task ReadOnlyList_ItemAt_Satisfies_Preserves_Set_Item_Source()
+ {
+ IReadOnlyList> items = new List>
+ {
+ new() { 1, 2, 3 }
+ };
+
+ await Assert.That(items).ItemAt(0).Satisfies(item => item.IsSupersetOf(new[] { 1, 2 }));
+ }
+
+ [Test]
+ public async Task ReadOnlyList_ItemAt_Satisfies_Preserves_ReadOnly_Set_Item_Source()
+ {
+ IReadOnlyList> items = new List>
+ {
+ new HashSet { 1, 2, 3 }
+ };
+
+ await Assert.That(items).ItemAt(0).Satisfies(item => item.IsSupersetOf(new[] { 1, 2 }));
+ }
+}
diff --git a/TUnit.Assertions.Tests/Bugs/Issue5778Tests.cs b/TUnit.Assertions.Tests/Bugs/Issue5778Tests.cs
new file mode 100644
index 0000000000..64f789bba0
--- /dev/null
+++ b/TUnit.Assertions.Tests/Bugs/Issue5778Tests.cs
@@ -0,0 +1,188 @@
+namespace TUnit.Assertions.Tests.Bugs;
+
+///
+/// Regression tests mirroring for the LastItem path.
+/// LastItem(...).Satisfies(...) should preserve specialised assertion sources for
+/// collection-like item values instead of exposing only IAssertionSource<TItem>.
+///
+public class Issue5778Tests
+{
+ [Test]
+ public async Task List_LastItem_Satisfies_Preserves_String_Item_Source()
+ {
+ IList items = new List { "alpha" };
+
+ await Assert.That(items).LastItem().Satisfies(item => item.Contains("pha"));
+ }
+
+ [Test]
+ public async Task List_LastItem_Satisfies_Preserves_Collection_Item_Source()
+ {
+ IList> items = new List> { new[] { 1, 2, 3 } };
+
+ await Assert.That(items).LastItem().Satisfies(item => item.Count().IsEqualTo(3));
+ }
+
+ [Test]
+ public async Task List_LastItem_Satisfies_Preserves_IList_Item_Source()
+ {
+ IList> items = new List> { new List { 1, 2, 3 } };
+
+ await Assert.That(items).LastItem().Satisfies(item => item.Count().IsEqualTo(3));
+ }
+
+ [Test]
+ public async Task List_LastItem_Satisfies_Preserves_IReadOnlyList_Item_Source()
+ {
+ IList> items = new List> { new List { 1, 2, 3 } };
+
+ await Assert.That(items).LastItem().Satisfies(item => item.Count().IsEqualTo(3));
+ }
+
+ [Test]
+ public async Task List_LastItem_Satisfies_Preserves_Array_Item_Source()
+ {
+ IList items = new List { new[] { 1, 2, 3 } };
+
+ await Assert.That(items).LastItem().Satisfies(item => item.Count().IsEqualTo(3));
+ }
+
+ [Test]
+ public async Task List_LastItem_Satisfies_Preserves_ConcreteList_Item_Source()
+ {
+ IList> items = new List> { new() { 1, 2, 3 } };
+
+ await Assert.That(items).LastItem().Satisfies(item => item.Count().IsEqualTo(3));
+ }
+
+ [Test]
+ public async Task List_LastItem_Satisfies_Preserves_Dictionary_Item_Source()
+ {
+ IList> items = new List>
+ {
+ new Dictionary { ["answer"] = 42 }
+ };
+
+ await Assert.That(items).LastItem().Satisfies(item => item.ContainsKey("answer"));
+ }
+
+ [Test]
+ public async Task List_LastItem_Satisfies_Preserves_Concrete_Dictionary_Item_Source()
+ {
+ IList> items = new List>
+ {
+ new() { ["answer"] = 42 }
+ };
+
+ await Assert.That(items).LastItem().Satisfies(item => item.ContainsKey("answer"));
+ }
+
+ [Test]
+ public async Task List_LastItem_Satisfies_Preserves_Set_Item_Source()
+ {
+ IList> items = new List>
+ {
+ new HashSet { 1, 2, 3 }
+ };
+
+ await Assert.That(items).LastItem().Satisfies(item => item.IsSupersetOf(new[] { 1, 2 }));
+ }
+
+ [Test]
+ public async Task List_LastItem_Satisfies_Preserves_HashSet_Item_Source()
+ {
+ IList> items = new List>
+ {
+ new() { 1, 2, 3 }
+ };
+
+ await Assert.That(items).LastItem().Satisfies(item => item.IsSupersetOf(new[] { 1, 2 }));
+ }
+
+ [Test]
+ public async Task ReadOnlyList_LastItem_Satisfies_Preserves_String_Item_Source()
+ {
+ IReadOnlyList items = new List { "alpha" };
+
+ await Assert.That(items).LastItem().Satisfies(item => item.Contains("pha"));
+ }
+
+ [Test]
+ public async Task ReadOnlyList_LastItem_Satisfies_Preserves_Collection_Item_Source()
+ {
+ IReadOnlyList> items = new List>
+ {
+ new() { 1, 2, 3 }
+ };
+
+ await Assert.That(items).LastItem().Satisfies(item => item.Count().IsEqualTo(3));
+ }
+
+ [Test]
+ public async Task ReadOnlyList_LastItem_Satisfies_Preserves_IList_Item_Source()
+ {
+ IReadOnlyList> items = new List> { new List { 1, 2, 3 } };
+
+ await Assert.That(items).LastItem().Satisfies(item => item.Count().IsEqualTo(3));
+ }
+
+ [Test]
+ public async Task ReadOnlyList_LastItem_Satisfies_Preserves_IReadOnlyList_Item_Source()
+ {
+ IReadOnlyList> items = new List> { new List { 1, 2, 3 } };
+
+ await Assert.That(items).LastItem().Satisfies(item => item.Count().IsEqualTo(3));
+ }
+
+ [Test]
+ public async Task ReadOnlyList_LastItem_Satisfies_Preserves_Array_Item_Source()
+ {
+ IReadOnlyList items = new List { new[] { 1, 2, 3 } };
+
+ await Assert.That(items).LastItem().Satisfies(item => item.Count().IsEqualTo(3));
+ }
+
+ [Test]
+ public async Task ReadOnlyList_LastItem_Satisfies_Preserves_Dictionary_Item_Source()
+ {
+ IReadOnlyList> items = new List>
+ {
+ new() { ["answer"] = 42 }
+ };
+
+ await Assert.That(items).LastItem().Satisfies(item => item.ContainsKey("answer"));
+ }
+
+ [Test]
+ public async Task ReadOnlyList_LastItem_Satisfies_Preserves_ReadOnly_Dictionary_Item_Source()
+ {
+ IReadOnlyList> items = new List>
+ {
+ new Dictionary { ["answer"] = 42 }
+ };
+
+ await Assert.That(items).LastItem().Satisfies(item => item.ContainsKey("answer"));
+ }
+
+ [Test]
+ public async Task ReadOnlyList_LastItem_Satisfies_Preserves_Set_Item_Source()
+ {
+ IReadOnlyList> items = new List>
+ {
+ new() { 1, 2, 3 }
+ };
+
+ await Assert.That(items).LastItem().Satisfies(item => item.IsSupersetOf(new[] { 1, 2 }));
+ }
+
+ [Test]
+ public async Task ReadOnlyList_LastItem_Satisfies_Preserves_ReadOnly_Set_Item_Source()
+ {
+ IReadOnlyList> items = new List>
+ {
+ new HashSet { 1, 2, 3 }
+ };
+
+ await Assert.That(items).LastItem().Satisfies(item => item.IsSupersetOf(new[] { 1, 2 }));
+ }
+}
diff --git a/TUnit.Assertions/Conditions/ListAssertions.cs b/TUnit.Assertions/Conditions/ListAssertions.cs
index 904de2ad88..4ca5e25cc4 100644
--- a/TUnit.Assertions/Conditions/ListAssertions.cs
+++ b/TUnit.Assertions/Conditions/ListAssertions.cs
@@ -62,6 +62,9 @@ protected override Task CheckAsync(EvaluationMetadata me
/// This enables patterns like: Assert.That(list).ItemAt(0).IsEqualTo(expected)
///
public class ListItemAtSource : IAssertionSource
+#if !NETSTANDARD2_0
+ , IItemSatisfiesSource>
+#endif
where TList : IList
{
private readonly AssertionContext _listContext;
@@ -126,6 +129,33 @@ public ListItemAtNullAssertion IsNotNull()
public ListItemAtSatisfiesAssertion Satisfies(
Func, Assertion?> assertion,
[CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ {
+ return CreateSatisfiesAssertion(
+ (item, index) => assertion(new ValueAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+#if !NETSTANDARD2_0
+ ///
+ /// Asserts that the item at the index satisfies the given assertion expressed against
+ /// a specialised assertion source . The source is constructed
+ /// per-item via its static factory.
+ /// Specify explicitly or via a typed lambda parameter.
+ ///
+ public ListItemAtSatisfiesAssertion Satisfies(
+ Func assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TSource : IAssertionSourceFor
+ {
+ return CreateSatisfiesAssertion(
+ (item, index) => assertion(TSource.Create(item, $"item[{index}]")),
+ expression);
+ }
+#endif
+
+ internal ListItemAtSatisfiesAssertion CreateSatisfiesAssertion(
+ Func assertion,
+ string? expression)
{
_listContext.ExpressionBuilder.Append($".Satisfies({expression})");
return new ListItemAtSatisfiesAssertion(_listContext, _index, assertion);
@@ -179,6 +209,9 @@ public IsNotAssignableFromAssertion IsNotAssignableFrom
public class ListLastItemSource : IAssertionSource
+#if !NETSTANDARD2_0
+ , IItemSatisfiesSource>
+#endif
where TList : IList
{
private readonly AssertionContext _listContext;
@@ -241,6 +274,33 @@ public ListLastItemNullAssertion IsNotNull()
public ListLastItemSatisfiesAssertion Satisfies(
Func, Assertion?> assertion,
[CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ {
+ return CreateSatisfiesAssertion(
+ item => assertion(new ValueAssertion(item, "lastItem")),
+ expression);
+ }
+
+#if !NETSTANDARD2_0
+ ///
+ /// Asserts that the last item satisfies the given assertion expressed against
+ /// a specialised assertion source . The source is constructed
+ /// from the last item via its static factory.
+ /// Specify explicitly or via a typed lambda parameter.
+ ///
+ public ListLastItemSatisfiesAssertion Satisfies(
+ Func assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TSource : IAssertionSourceFor
+ {
+ return CreateSatisfiesAssertion(
+ item => assertion(TSource.Create(item, "lastItem")),
+ expression);
+ }
+#endif
+
+ internal ListLastItemSatisfiesAssertion CreateSatisfiesAssertion(
+ Func assertion,
+ string? expression)
{
_listContext.ExpressionBuilder.Append($".Satisfies({expression})");
return new ListLastItemSatisfiesAssertion(_listContext, assertion);
@@ -420,12 +480,30 @@ public class ListItemAtSatisfiesAssertion : ListAssertionBase
{
private readonly int _index;
- private readonly Func, Assertion?> _assertion;
+ private readonly Func _assertion;
+ ///
+ /// Backward-compatible constructor preserved for direct consumers from before the
+ /// internal refactor to a raw-item delegate. Wraps the supplied factory in a
+ /// — the unspecialised path. Internal callers should
+ /// use the entry point
+ /// to get specialised assertion sources.
+ ///
public ListItemAtSatisfiesAssertion(
AssertionContext context,
int index,
Func, Assertion?> assertion)
+ : this(
+ context,
+ index,
+ (item, itemIndex) => assertion(new ValueAssertion(item, $"item[{itemIndex}]")))
+ {
+ }
+
+ internal ListItemAtSatisfiesAssertion(
+ AssertionContext context,
+ int index,
+ Func assertion)
: base(context)
{
_index = index;
@@ -451,8 +529,7 @@ protected override async Task CheckAsync(EvaluationMetadata(actualItem, $"item[{_index}]");
- var resultingAssertion = _assertion(itemSource);
+ var resultingAssertion = _assertion(actualItem, _index);
if (resultingAssertion != null)
{
@@ -595,11 +672,25 @@ protected override string GetExpectation() =>
public class ListLastItemSatisfiesAssertion : ListAssertionBase
where TList : IList
{
- private readonly Func, Assertion?> _assertion;
+ private readonly Func _assertion;
+ ///
+ /// Backward-compatible constructor preserved for direct consumers from before the
+ /// internal refactor to a raw-item delegate. Wraps the supplied factory in a
+ /// — the unspecialised path. Internal callers should
+ /// use the entry point
+ /// to get specialised assertion sources.
+ ///
public ListLastItemSatisfiesAssertion(
AssertionContext context,
Func, Assertion?> assertion)
+ : this(context, item => assertion(new ValueAssertion(item, "lastItem")))
+ {
+ }
+
+ internal ListLastItemSatisfiesAssertion(
+ AssertionContext context,
+ Func assertion)
: base(context)
{
_assertion = assertion;
@@ -623,8 +714,7 @@ protected override async Task CheckAsync(EvaluationMetadata(lastItem, "lastItem");
- var resultingAssertion = _assertion(itemSource);
+ var resultingAssertion = _assertion(lastItem);
if (resultingAssertion != null)
{
diff --git a/TUnit.Assertions/Conditions/ReadOnlyListAssertions.cs b/TUnit.Assertions/Conditions/ReadOnlyListAssertions.cs
index 5d91d94373..f098cedeee 100644
--- a/TUnit.Assertions/Conditions/ReadOnlyListAssertions.cs
+++ b/TUnit.Assertions/Conditions/ReadOnlyListAssertions.cs
@@ -64,6 +64,9 @@ protected override Task CheckAsync(EvaluationMetadata me
/// This enables patterns like: Assert.That(list).ItemAt(0).IsEqualTo(expected)
///
public class ReadOnlyListItemAtSource : IAssertionSource
+#if !NETSTANDARD2_0
+ , IItemSatisfiesSource>
+#endif
where TList : IReadOnlyList
{
private readonly AssertionContext _listContext;
@@ -128,6 +131,33 @@ public ReadOnlyListItemAtNullAssertion IsNotNull()
public ReadOnlyListItemAtSatisfiesAssertion Satisfies(
Func, Assertion?> assertion,
[CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ {
+ return CreateSatisfiesAssertion(
+ (item, index) => assertion(new ValueAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+#if !NETSTANDARD2_0
+ ///
+ /// Asserts that the item at the index satisfies the given assertion expressed against
+ /// a specialised assertion source . The source is constructed
+ /// per-item via its static factory.
+ /// Specify explicitly or via a typed lambda parameter.
+ ///
+ public ReadOnlyListItemAtSatisfiesAssertion Satisfies(
+ Func assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TSource : IAssertionSourceFor
+ {
+ return CreateSatisfiesAssertion(
+ (item, index) => assertion(TSource.Create(item, $"item[{index}]")),
+ expression);
+ }
+#endif
+
+ internal ReadOnlyListItemAtSatisfiesAssertion CreateSatisfiesAssertion(
+ Func assertion,
+ string? expression)
{
_listContext.ExpressionBuilder.Append($".Satisfies({expression})");
return new ReadOnlyListItemAtSatisfiesAssertion(_listContext, _index, assertion);
@@ -181,6 +211,9 @@ public IsNotAssignableFromAssertion IsNotAssignableFrom
public class ReadOnlyListLastItemSource : IAssertionSource
+#if !NETSTANDARD2_0
+ , IItemSatisfiesSource>
+#endif
where TList : IReadOnlyList
{
private readonly AssertionContext _listContext;
@@ -243,6 +276,33 @@ public ReadOnlyListLastItemNullAssertion IsNotNull()
public ReadOnlyListLastItemSatisfiesAssertion Satisfies(
Func, Assertion?> assertion,
[CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ {
+ return CreateSatisfiesAssertion(
+ item => assertion(new ValueAssertion(item, "lastItem")),
+ expression);
+ }
+
+#if !NETSTANDARD2_0
+ ///
+ /// Asserts that the last item satisfies the given assertion expressed against
+ /// a specialised assertion source . The source is constructed
+ /// from the last item via its static factory.
+ /// Specify explicitly or via a typed lambda parameter.
+ ///
+ public ReadOnlyListLastItemSatisfiesAssertion Satisfies(
+ Func assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TSource : IAssertionSourceFor
+ {
+ return CreateSatisfiesAssertion(
+ item => assertion(TSource.Create(item, "lastItem")),
+ expression);
+ }
+#endif
+
+ internal ReadOnlyListLastItemSatisfiesAssertion CreateSatisfiesAssertion(
+ Func assertion,
+ string? expression)
{
_listContext.ExpressionBuilder.Append($".Satisfies({expression})");
return new ReadOnlyListLastItemSatisfiesAssertion(_listContext, assertion);
@@ -422,12 +482,30 @@ public class ReadOnlyListItemAtSatisfiesAssertion : ReadOnlyListAs
where TList : IReadOnlyList
{
private readonly int _index;
- private readonly Func, Assertion?> _assertion;
+ private readonly Func _assertion;
+ ///
+ /// Backward-compatible constructor preserved for direct consumers from before the
+ /// internal refactor to a raw-item delegate. Wraps the supplied factory in a
+ /// — the unspecialised path. Internal callers should
+ /// use the entry
+ /// point to get specialised assertion sources.
+ ///
public ReadOnlyListItemAtSatisfiesAssertion(
AssertionContext context,
int index,
Func, Assertion?> assertion)
+ : this(
+ context,
+ index,
+ (item, itemIndex) => assertion(new ValueAssertion(item, $"item[{itemIndex}]")))
+ {
+ }
+
+ internal ReadOnlyListItemAtSatisfiesAssertion(
+ AssertionContext context,
+ int index,
+ Func assertion)
: base(context)
{
_index = index;
@@ -453,8 +531,7 @@ protected override async Task CheckAsync(EvaluationMetadata(actualItem, $"item[{_index}]");
- var resultingAssertion = _assertion(itemSource);
+ var resultingAssertion = _assertion(actualItem, _index);
if (resultingAssertion != null)
{
@@ -597,11 +674,25 @@ protected override string GetExpectation() =>
public class ReadOnlyListLastItemSatisfiesAssertion : ReadOnlyListAssertionBase
where TList : IReadOnlyList
{
- private readonly Func, Assertion?> _assertion;
+ private readonly Func _assertion;
+ ///
+ /// Backward-compatible constructor preserved for direct consumers from before the
+ /// internal refactor to a raw-item delegate. Wraps the supplied factory in a
+ /// — the unspecialised path. Internal callers should
+ /// use the
+ /// entry point to get specialised assertion sources.
+ ///
public ReadOnlyListLastItemSatisfiesAssertion(
AssertionContext context,
Func, Assertion?> assertion)
+ : this(context, item => assertion(new ValueAssertion(item, "lastItem")))
+ {
+ }
+
+ internal ReadOnlyListLastItemSatisfiesAssertion(
+ AssertionContext context,
+ Func assertion)
: base(context)
{
_assertion = assertion;
@@ -625,8 +716,7 @@ protected override async Task CheckAsync(EvaluationMetadata(lastItem, "lastItem");
- var resultingAssertion = _assertion(itemSource);
+ var resultingAssertion = _assertion(lastItem);
if (resultingAssertion != null)
{
diff --git a/TUnit.Assertions/Core/IAssertionSourceFor.cs b/TUnit.Assertions/Core/IAssertionSourceFor.cs
new file mode 100644
index 0000000000..fff3664693
--- /dev/null
+++ b/TUnit.Assertions/Core/IAssertionSourceFor.cs
@@ -0,0 +1,34 @@
+#if !NETSTANDARD2_0
+namespace TUnit.Assertions.Core;
+
+///
+/// Marks an assertion source as constructible from a raw value, enabling generic
+/// dispatch through Satisfies<TSource> on item-at and similar entry points.
+/// Implementations expose a static factory used to materialise the specialised source
+/// per-item without per-shape overload enumeration.
+///
+///
+/// Intentionally does not extend : a single
+/// implementing class can implement multiple parameterisations of this interface
+/// (e.g. against both an interface item type and a matching concrete type) without
+/// triggering conflicting Context property requirements.
+///
+/// The value type the source wraps.
+/// The implementing source type (CRTP).
+public interface IAssertionSourceFor
+ where TSelf : IAssertionSourceFor
+{
+ ///
+ /// Constructs the specialised assertion source wrapping with the
+ /// given .
+ ///
+ ///
+ /// Implementations may receive a when the
+ /// caller selects from a collection that allows nulls. Whether the resulting assertion
+ /// surfaces a graceful failure or throws on a subsequent operation is the implementer's
+ /// responsibility — the contract is "construct a source that callers can run assertions
+ /// against", not "validate non-null up front".
+ ///
+ static abstract TSelf Create(TItem item, string label);
+}
+#endif
diff --git a/TUnit.Assertions/Core/IItemSatisfiesSource.cs b/TUnit.Assertions/Core/IItemSatisfiesSource.cs
new file mode 100644
index 0000000000..464851281b
--- /dev/null
+++ b/TUnit.Assertions/Core/IItemSatisfiesSource.cs
@@ -0,0 +1,24 @@
+#if !NETSTANDARD2_0
+namespace TUnit.Assertions.Core;
+
+///
+/// Common shape for item-selector sources (ItemAt, LastItem, etc.) that expose a
+/// generic Satisfies<TSource> entry. Lets a single set of per-shape
+/// extension overloads dispatch across all selector variants by binding to this
+/// interface rather than each concrete source type.
+///
+///
+/// Extends so that extension methods targeting
+/// this interface bind in preference to those targeting
+/// directly — without that, the predicate-style Satisfies(Func<TItem, bool>)
+/// extension can win overload resolution for items whose specialised assertion methods
+/// return bool (e.g. IDictionary.ContainsKey).
+///
+/// The selected item's type.
+/// The selector-specific assertion result type.
+public interface IItemSatisfiesSource : IAssertionSource
+{
+ TResult Satisfies(Func assertion, string? expression = null)
+ where TSource : IAssertionSourceFor;
+}
+#endif
diff --git a/TUnit.Assertions/Extensions/CollectionItemSatisfiesExtensions.cs b/TUnit.Assertions/Extensions/CollectionItemSatisfiesExtensions.cs
new file mode 100644
index 0000000000..3bb52670fc
--- /dev/null
+++ b/TUnit.Assertions/Extensions/CollectionItemSatisfiesExtensions.cs
@@ -0,0 +1,99 @@
+#if !NETSTANDARD2_0
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Sources;
+
+namespace TUnit.Assertions.Extensions;
+
+///
+/// Specialised Satisfies overloads when the selected item (from ItemAt,
+/// LastItem, etc.) is itself a collection-like value. Each overload binds the
+/// matching implementation and dispatches
+/// through ,
+/// so a single set of per-shape overloads serves every item-selector source type.
+///
+///
+/// Both interface-shaped (e.g. IList<T>) and concrete-shaped
+/// (e.g. List<T>) overloads are required because C# overload
+/// resolution performs exact type matching on the source's TItem:
+/// IList<List<int>> binds the List<TInner>
+/// overload, never the IList<TInner> one.
+///
+/// When introducing a new
+/// implementation, add the matching overload below; otherwise users must
+/// spell out the type argument explicitly: .Satisfies<MyAssertion<T>>(...).
+///
+public static class CollectionItemSatisfiesExtensions
+{
+ public static TResult Satisfies(
+ this IItemSatisfiesSource, TResult> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ => source.Satisfies>(assertion, expression);
+
+ public static TResult Satisfies(
+ this IItemSatisfiesSource, TResult> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ => source.Satisfies>(assertion, expression);
+
+ public static TResult Satisfies(
+ this IItemSatisfiesSource, TResult> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ => source.Satisfies>(assertion, expression);
+
+ public static TResult Satisfies(
+ this IItemSatisfiesSource, TResult> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TKey : notnull
+ => source.Satisfies>(assertion, expression);
+
+ public static TResult Satisfies(
+ this IItemSatisfiesSource, TResult> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TKey : notnull
+ => source.Satisfies>(assertion, expression);
+
+ public static TResult Satisfies(
+ this IItemSatisfiesSource, TResult> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ => source.Satisfies>(assertion, expression);
+
+#if NET5_0_OR_GREATER
+ public static TResult Satisfies(
+ this IItemSatisfiesSource