From 20bdb042b3dc39fbb092f3f7dfb056e41a1cbd5a Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sun, 26 Apr 2026 22:21:43 +0100
Subject: [PATCH 01/10] fix(assertions): preserve item-at source types
Closes #5706
---
TUnit.Assertions.Tests/Bugs/Issue5706Tests.cs | 88 +++++
TUnit.Assertions/Conditions/ListAssertions.cs | 20 +-
.../Conditions/ReadOnlyListAssertions.cs | 20 +-
.../ListItemAtSatisfiesExtensions.cs | 311 ++++++++++++++++++
...Has_No_API_Changes.DotNet10_0.verified.txt | 53 +++
..._Has_No_API_Changes.DotNet8_0.verified.txt | 53 +++
..._Has_No_API_Changes.DotNet9_0.verified.txt | 53 +++
...ary_Has_No_API_Changes.Net4_7.verified.txt | 49 +++
8 files changed, 641 insertions(+), 6 deletions(-)
create mode 100644 TUnit.Assertions.Tests/Bugs/Issue5706Tests.cs
create mode 100644 TUnit.Assertions/Extensions/ListItemAtSatisfiesExtensions.cs
diff --git a/TUnit.Assertions.Tests/Bugs/Issue5706Tests.cs b/TUnit.Assertions.Tests/Bugs/Issue5706Tests.cs
new file mode 100644
index 0000000000..48c13fb947
--- /dev/null
+++ b/TUnit.Assertions.Tests/Bugs/Issue5706Tests.cs
@@ -0,0 +1,88 @@
+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_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_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 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_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_Set_Item_Source()
+ {
+ IReadOnlyList> items = new List>
+ {
+ new() { 1, 2, 3 }
+ };
+
+ await Assert.That(items).ItemAt(0).Satisfies(item => item.IsSupersetOf(new[] { 1, 2 }));
+ }
+}
diff --git a/TUnit.Assertions/Conditions/ListAssertions.cs b/TUnit.Assertions/Conditions/ListAssertions.cs
index 904de2ad88..7b534956bd 100644
--- a/TUnit.Assertions/Conditions/ListAssertions.cs
+++ b/TUnit.Assertions/Conditions/ListAssertions.cs
@@ -67,6 +67,10 @@ public class ListItemAtSource : IAssertionSource
private readonly AssertionContext _listContext;
private readonly int _index;
+ internal AssertionContext InternalListContext => _listContext;
+
+ internal int InternalIndex => _index;
+
public AssertionContext Context { get; }
public ListItemAtSource(AssertionContext listContext, int index)
@@ -420,12 +424,23 @@ public class ListItemAtSatisfiesAssertion : ListAssertionBase
{
private readonly int _index;
- private readonly Func, Assertion?> _assertion;
+ private readonly Func _assertion;
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 +466,7 @@ protected override async Task CheckAsync(EvaluationMetadata(actualItem, $"item[{_index}]");
- var resultingAssertion = _assertion(itemSource);
+ var resultingAssertion = _assertion(actualItem, _index);
if (resultingAssertion != null)
{
diff --git a/TUnit.Assertions/Conditions/ReadOnlyListAssertions.cs b/TUnit.Assertions/Conditions/ReadOnlyListAssertions.cs
index 5d91d94373..40397828f5 100644
--- a/TUnit.Assertions/Conditions/ReadOnlyListAssertions.cs
+++ b/TUnit.Assertions/Conditions/ReadOnlyListAssertions.cs
@@ -69,6 +69,10 @@ public class ReadOnlyListItemAtSource : IAssertionSource
private readonly AssertionContext _listContext;
private readonly int _index;
+ internal AssertionContext InternalListContext => _listContext;
+
+ internal int InternalIndex => _index;
+
public AssertionContext Context { get; }
public ReadOnlyListItemAtSource(AssertionContext listContext, int index)
@@ -422,12 +426,23 @@ public class ReadOnlyListItemAtSatisfiesAssertion : ReadOnlyListAs
where TList : IReadOnlyList
{
private readonly int _index;
- private readonly Func, Assertion?> _assertion;
+ private readonly Func _assertion;
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 +468,7 @@ protected override async Task CheckAsync(EvaluationMetadata(actualItem, $"item[{_index}]");
- var resultingAssertion = _assertion(itemSource);
+ var resultingAssertion = _assertion(actualItem, _index);
if (resultingAssertion != null)
{
diff --git a/TUnit.Assertions/Extensions/ListItemAtSatisfiesExtensions.cs b/TUnit.Assertions/Extensions/ListItemAtSatisfiesExtensions.cs
new file mode 100644
index 0000000000..177257fc0a
--- /dev/null
+++ b/TUnit.Assertions/Extensions/ListItemAtSatisfiesExtensions.cs
@@ -0,0 +1,311 @@
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Conditions;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Sources;
+
+namespace TUnit.Assertions.Extensions;
+
+///
+/// Specialised overloads for ItemAt(...).Satisfies(...) when the item is itself
+/// a collection-like value.
+///
+public static class ListItemAtSatisfiesExtensions
+{
+ public static ListItemAtSatisfiesAssertion> Satisfies(
+ this ListItemAtSource> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IList>
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new CollectionAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+ public static ReadOnlyListItemAtSatisfiesAssertion> Satisfies(
+ this ReadOnlyListItemAtSource> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IReadOnlyList>
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new CollectionAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+ public static ListItemAtSatisfiesAssertion> Satisfies(
+ this ListItemAtSource> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IList>
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new ListAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+ public static ReadOnlyListItemAtSatisfiesAssertion> Satisfies(
+ this ReadOnlyListItemAtSource> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IReadOnlyList>
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new ListAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+ public static ListItemAtSatisfiesAssertion> Satisfies(
+ this ListItemAtSource> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IList>
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new ReadOnlyListAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+ public static ReadOnlyListItemAtSatisfiesAssertion> Satisfies(
+ this ReadOnlyListItemAtSource> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IReadOnlyList>
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new ReadOnlyListAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+ public static ListItemAtSatisfiesAssertion> Satisfies(
+ this ListItemAtSource> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IList>
+ where TKey : notnull
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new DictionaryAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+ public static ReadOnlyListItemAtSatisfiesAssertion> Satisfies(
+ this ReadOnlyListItemAtSource> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IReadOnlyList>
+ where TKey : notnull
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new DictionaryAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+ public static ListItemAtSatisfiesAssertion> Satisfies(
+ this ListItemAtSource> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IList>
+ where TKey : notnull
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new MutableDictionaryAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+ public static ReadOnlyListItemAtSatisfiesAssertion> Satisfies(
+ this ReadOnlyListItemAtSource> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IReadOnlyList>
+ where TKey : notnull
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new MutableDictionaryAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+ public static ListItemAtSatisfiesAssertion> Satisfies(
+ this ListItemAtSource> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IList>
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new SetAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+ public static ReadOnlyListItemAtSatisfiesAssertion> Satisfies(
+ this ReadOnlyListItemAtSource> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IReadOnlyList>
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new SetAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+#if NET5_0_OR_GREATER
+ public static ListItemAtSatisfiesAssertion> Satisfies(
+ this ListItemAtSource> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IList>
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new ReadOnlySetAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+ public static ReadOnlyListItemAtSatisfiesAssertion> Satisfies(
+ this ReadOnlyListItemAtSource> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IReadOnlyList>
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new ReadOnlySetAssertion(item, $"item[{index}]")),
+ expression);
+ }
+#endif
+
+ public static ListItemAtSatisfiesAssertion Satisfies(
+ this ListItemAtSource source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IList
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new ArrayAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+ public static ReadOnlyListItemAtSatisfiesAssertion Satisfies(
+ this ReadOnlyListItemAtSource source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IReadOnlyList
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new ArrayAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+ public static ListItemAtSatisfiesAssertion> Satisfies(
+ this ListItemAtSource> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IList>
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new ListAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+ public static ReadOnlyListItemAtSatisfiesAssertion> Satisfies(
+ this ReadOnlyListItemAtSource> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IReadOnlyList>
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new ListAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+ public static ListItemAtSatisfiesAssertion> Satisfies(
+ this ListItemAtSource> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IList>
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new HashSetAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+ public static ReadOnlyListItemAtSatisfiesAssertion> Satisfies(
+ this ReadOnlyListItemAtSource> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IReadOnlyList>
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new HashSetAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+ public static ListItemAtSatisfiesAssertion> Satisfies(
+ this ListItemAtSource> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IList>
+ where TKey : notnull
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new MutableDictionaryAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+ public static ReadOnlyListItemAtSatisfiesAssertion> Satisfies(
+ this ReadOnlyListItemAtSource> source,
+ Func, IAssertion?> assertion,
+ [CallerArgumentExpression(nameof(assertion))] string? expression = null)
+ where TList : IReadOnlyList>
+ where TKey : notnull
+ {
+ return SatisfiesSpecialised(
+ source,
+ (item, index) => assertion(new MutableDictionaryAssertion(item, $"item[{index}]")),
+ expression);
+ }
+
+ private static ListItemAtSatisfiesAssertion SatisfiesSpecialised(
+ ListItemAtSource source,
+ Func assertionFactory,
+ string? expression)
+ where TList : IList
+ {
+ source.InternalListContext.ExpressionBuilder.Append($".Satisfies({expression})");
+ return new ListItemAtSatisfiesAssertion(
+ source.InternalListContext,
+ source.InternalIndex,
+ assertionFactory);
+ }
+
+ private static ReadOnlyListItemAtSatisfiesAssertion SatisfiesSpecialised(
+ ReadOnlyListItemAtSource source,
+ Func assertionFactory,
+ string? expression)
+ where TList : IReadOnlyList
+ {
+ source.InternalListContext.ExpressionBuilder.Append($".Satisfies({expression})");
+ return new ReadOnlyListItemAtSatisfiesAssertion(
+ source.InternalListContext,
+ source.InternalIndex,
+ assertionFactory);
+ }
+}
diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt
index 1923ab110f..6655d53a00 100644
--- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt
+++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt
@@ -4715,6 +4715,59 @@ namespace .Extensions
protected override .<.> CheckAsync(.<> metadata) { }
protected override string GetExpectation() { }
}
+ public static class ListItemAtSatisfiesExtensions
+ {
+ public static .> Satisfies(this .> source, <., .?> assertion, [.("assertion")] string? expression = null)
+ where TList : .<.> { }
+ public static .> Satisfies(this .> source, <., .?> assertion, [.("assertion")] string? expression = null)
+ where TList : .<.> { }
+ public static .> Satisfies(this .> source, <., .?> assertion, [.("assertion")] string? expression = null)
+ where TList : .<.> { }
+ public static .> Satisfies(this .> source, <., .?> assertion, [.("assertion")] string? expression = null)
+ where TList : .<.> { }
+ public static .> Satisfies(this .> source, <., .?> assertion, [.("assertion")] string? expression = null)
+ where TList : .<.> { }
+ public static .> Satisfies(this .> source, <., .?> assertion, [.("assertion")] string? expression = null)
+ where TList : .<.> { }
+ public static .> Satisfies(this .> source, <., .?> assertion, [.("assertion")] string? expression = null)
+ where TList : .<.> { }
+ public static . Satisfies(this . source, <., .?> assertion, [.("assertion")] string? expression = null)
+ where TList : . { }
+ public static .> Satisfies(this .> source, <., .?> assertion, [.("assertion")] string? expression = null)
+ where TList : .<.> { }
+ public static .> Satisfies(this .> source, <., .?> assertion, [.("assertion")] string? expression = null)
+ where TList : .<.> { }
+ public static .> Satisfies(this .> source, <.