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

/// <summary>
/// Regression tests for GitHub issue #5706:
/// ItemAt(...).Satisfies(...) should preserve specialised assertion sources for
/// collection-like item values instead of exposing only IAssertionSource&lt;TItem&gt;.
/// </summary>
public class Issue5706Tests
{
[Test]
public async Task List_ItemAt_Satisfies_Preserves_String_Item_Source()
{
IList<string> items = new List<string> { "alpha" };

await Assert.That(items).ItemAt(0).Satisfies(item => item.Contains("pha"));
}

[Test]
public async Task List_ItemAt_Satisfies_Preserves_Collection_Item_Source()
{
IList<IEnumerable<int>> items = new List<IEnumerable<int>> { new[] { 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<int[]> items = new List<int[]> { 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<IDictionary<string, int>> items = new List<IDictionary<string, int>>
{
new Dictionary<string, int> { ["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<Dictionary<string, int>> items = new List<Dictionary<string, int>>
{
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<ISet<int>> items = new List<ISet<int>>
{
new HashSet<int> { 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<HashSet<int>> items = new List<HashSet<int>>
{
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<string> items = new List<string> { "alpha" };

await Assert.That(items).ItemAt(0).Satisfies(item => item.Contains("pha"));
}

[Test]
public async Task ReadOnlyList_ItemAt_Satisfies_Preserves_Collection_Item_Source()
{
IReadOnlyList<List<int>> items = new List<List<int>>
{
new() { 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<int[]> items = new List<int[]> { 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<Dictionary<string, int>> items = new List<Dictionary<string, int>>
{
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<IReadOnlyDictionary<string, int>> items = new List<IReadOnlyDictionary<string, int>>
{
new Dictionary<string, int> { ["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<HashSet<int>> items = new List<HashSet<int>>
{
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<IReadOnlySet<int>> items = new List<IReadOnlySet<int>>
{
new HashSet<int> { 1, 2, 3 }
};

await Assert.That(items).ItemAt(0).Satisfies(item => item.IsSupersetOf(new[] { 1, 2 }));
}
}
43 changes: 40 additions & 3 deletions TUnit.Assertions/Conditions/ListAssertions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,33 @@ public ListItemAtNullAssertion<TList, TItem> IsNotNull()
public ListItemAtSatisfiesAssertion<TList, TItem> Satisfies(
Func<IAssertionSource<TItem>, Assertion<TItem>?> assertion,
[CallerArgumentExpression(nameof(assertion))] string? expression = null)
{
return CreateSatisfiesAssertion(
(item, index) => assertion(new ValueAssertion<TItem>(item, $"item[{index}]")),
expression);
}

#if !NETSTANDARD2_0
/// <summary>
/// Asserts that the item at the index satisfies the given assertion expressed against
/// a specialised assertion source <typeparamref name="TSource"/>. The source is constructed
/// per-item via its static <see cref="IAssertionSourceFor{TItem,TSelf}.Create"/> factory.
/// Specify <typeparamref name="TSource"/> explicitly or via a typed lambda parameter.
/// </summary>
public ListItemAtSatisfiesAssertion<TList, TItem> Satisfies<TSource>(
Func<TSource, IAssertion?> assertion,
[CallerArgumentExpression(nameof(assertion))] string? expression = null)
where TSource : IAssertionSourceFor<TItem, TSource>
{
return CreateSatisfiesAssertion(
(item, index) => assertion(TSource.Create(item, $"item[{index}]")),
expression);
}
#endif

internal ListItemAtSatisfiesAssertion<TList, TItem> CreateSatisfiesAssertion(
Func<TItem, int, IAssertion?> assertion,
string? expression)
{
_listContext.ExpressionBuilder.Append($".Satisfies({expression})");
return new ListItemAtSatisfiesAssertion<TList, TItem>(_listContext, _index, assertion);
Expand Down Expand Up @@ -420,12 +447,23 @@ public class ListItemAtSatisfiesAssertion<TList, TItem> : ListAssertionBase<TLis
where TList : IList<TItem>
{
private readonly int _index;
private readonly Func<IAssertionSource<TItem>, Assertion<TItem>?> _assertion;
private readonly Func<TItem, int, IAssertion?> _assertion;

public ListItemAtSatisfiesAssertion(
AssertionContext<TList> context,
int index,
Func<IAssertionSource<TItem>, Assertion<TItem>?> assertion)
: this(
context,
index,
(item, itemIndex) => assertion(new ValueAssertion<TItem>(item, $"item[{itemIndex}]")))
{
}

internal ListItemAtSatisfiesAssertion(
AssertionContext<TList> context,
int index,
Func<TItem, int, IAssertion?> assertion)
: base(context)
{
_index = index;
Expand All @@ -451,8 +489,7 @@ protected override async Task<AssertionResult> CheckAsync(EvaluationMetadata<TLi
}

var actualItem = metadata.Value[_index];
var itemSource = new ValueAssertion<TItem>(actualItem, $"item[{_index}]");
var resultingAssertion = _assertion(itemSource);
var resultingAssertion = _assertion(actualItem, _index);

if (resultingAssertion != null)
{
Expand Down
43 changes: 40 additions & 3 deletions TUnit.Assertions/Conditions/ReadOnlyListAssertions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,33 @@ public ReadOnlyListItemAtNullAssertion<TList, TItem> IsNotNull()
public ReadOnlyListItemAtSatisfiesAssertion<TList, TItem> Satisfies(
Func<IAssertionSource<TItem>, Assertion<TItem>?> assertion,
[CallerArgumentExpression(nameof(assertion))] string? expression = null)
{
return CreateSatisfiesAssertion(
(item, index) => assertion(new ValueAssertion<TItem>(item, $"item[{index}]")),
expression);
}

#if !NETSTANDARD2_0
/// <summary>
/// Asserts that the item at the index satisfies the given assertion expressed against
/// a specialised assertion source <typeparamref name="TSource"/>. The source is constructed
/// per-item via its static <see cref="IAssertionSourceFor{TItem,TSelf}.Create"/> factory.
/// Specify <typeparamref name="TSource"/> explicitly or via a typed lambda parameter.
/// </summary>
public ReadOnlyListItemAtSatisfiesAssertion<TList, TItem> Satisfies<TSource>(
Func<TSource, IAssertion?> assertion,
[CallerArgumentExpression(nameof(assertion))] string? expression = null)
where TSource : IAssertionSourceFor<TItem, TSource>
{
return CreateSatisfiesAssertion(
(item, index) => assertion(TSource.Create(item, $"item[{index}]")),
expression);
}
#endif

internal ReadOnlyListItemAtSatisfiesAssertion<TList, TItem> CreateSatisfiesAssertion(
Func<TItem, int, IAssertion?> assertion,
string? expression)
{
_listContext.ExpressionBuilder.Append($".Satisfies({expression})");
return new ReadOnlyListItemAtSatisfiesAssertion<TList, TItem>(_listContext, _index, assertion);
Expand Down Expand Up @@ -422,12 +449,23 @@ public class ReadOnlyListItemAtSatisfiesAssertion<TList, TItem> : ReadOnlyListAs
where TList : IReadOnlyList<TItem>
{
private readonly int _index;
private readonly Func<IAssertionSource<TItem>, Assertion<TItem>?> _assertion;
private readonly Func<TItem, int, IAssertion?> _assertion;

public ReadOnlyListItemAtSatisfiesAssertion(
AssertionContext<TList> context,
int index,
Func<IAssertionSource<TItem>, Assertion<TItem>?> assertion)
: this(
context,
index,
(item, itemIndex) => assertion(new ValueAssertion<TItem>(item, $"item[{itemIndex}]")))
{
}

internal ReadOnlyListItemAtSatisfiesAssertion(
AssertionContext<TList> context,
int index,
Func<TItem, int, IAssertion?> assertion)
: base(context)
{
_index = index;
Expand All @@ -453,8 +491,7 @@ protected override async Task<AssertionResult> CheckAsync(EvaluationMetadata<TLi
}

var actualItem = metadata.Value[_index];
var itemSource = new ValueAssertion<TItem>(actualItem, $"item[{_index}]");
var resultingAssertion = _assertion(itemSource);
var resultingAssertion = _assertion(actualItem, _index);

if (resultingAssertion != null)
{
Expand Down
17 changes: 17 additions & 0 deletions TUnit.Assertions/Core/IAssertionSourceFor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#if !NETSTANDARD2_0
namespace TUnit.Assertions.Core;

/// <summary>
/// Marks an assertion source as constructible from a raw value, enabling generic
/// dispatch through <c>Satisfies&lt;TSource&gt;</c> 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.
/// </summary>
/// <typeparam name="TItem">The value type the source wraps.</typeparam>
/// <typeparam name="TSelf">The implementing source type (CRTP).</typeparam>
public interface IAssertionSourceFor<TItem, TSelf> : IAssertionSource<TItem>
where TSelf : IAssertionSourceFor<TItem, TSelf>
{
static abstract TSelf Create(TItem item, string label);
}
#endif
Loading
Loading