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

namespace TUnit.Assertions.Tests.Bugs;

/// <summary>
/// Regression tests for GitHub issue #5707:
/// `.Count(itemAssertion)` per-item overload only exposed a generic
/// `IAssertionSource&lt;TItem&gt;` inside the lambda, so specialised
/// assertions defined on collection / dictionary / set / list bases
/// (e.g. <c>HasCount</c>, <c>ContainsKey</c>, <c>IsSubsetOf</c>,
/// <c>HasItemAt</c>) were unreachable when the items themselves were
/// collections, dictionaries or sets.
///
/// Specialised <c>Count</c> overloads now hand the lambda a typed
/// source (CollectionAssertion, ListAssertion, DictionaryAssertion,
/// SetAssertion, etc.) so the failure message also keeps the
/// specialised assertion's expectation rather than a generic wrapper.
/// </summary>
public class Issue5707Tests
{
[Test]
public async Task Count_String_Items_Use_String_Assertion_Source()
{
var items = new List<string> { "apple", "banana", "apricot", "cherry" };

await Assert.That(items).Count(s => s.IsEqualTo("apple")).IsEqualTo(1);
}

[Test]
public async Task Count_Enumerable_Items_Reach_HasCount_On_Inner()
{
IEnumerable<IEnumerable<int>> listOfLists = new List<List<int>>
{
new() { 1, 2, 3 },
new() { 1 },
new() { 1, 2, 3 },
};

await Assert.That(listOfLists).Count(l => l.Count().IsEqualTo(3)).IsEqualTo(2);
}

[Test]
public async Task Count_List_Items_Reach_HasItemAt_On_Inner()
{
var listOfLists = new List<IList<int>>
{
new List<int> { 10, 20 },
new List<int> { 99 },
new List<int> { 10, 30 },
};

await Assert.That(listOfLists).Count(l => l.HasItemAt(0, 10)).IsEqualTo(2);
}

[Test]
public async Task Count_ReadOnlyList_Items_Reach_HasItemAt_On_Inner()
{
var listOfLists = new List<IReadOnlyList<int>>
{
new List<int> { 10, 20 },
new List<int> { 99 },
new List<int> { 10, 30 },
};

await Assert.That(listOfLists).Count(l => l.HasItemAt(0, 10)).IsEqualTo(2);
}

[Test]
public async Task Count_ReadOnlyDictionary_Items_Reach_ContainsKey_On_Inner()
{
IReadOnlyDictionary<string, int> a = new Dictionary<string, int> { ["k"] = 1 };
IReadOnlyDictionary<string, int> b = new Dictionary<string, int> { ["other"] = 2 };
IReadOnlyDictionary<string, int> c = new Dictionary<string, int> { ["k"] = 5 };
var dicts = new List<IReadOnlyDictionary<string, int>> { a, b, c };

await Assert.That(dicts).Count(d => d.ContainsKey("k")).IsEqualTo(2);
}

[Test]
public async Task Count_Dictionary_Items_Reach_ContainsKey_On_Inner()
{
var dicts = new List<IDictionary<string, int>>
{
new Dictionary<string, int> { ["k"] = 1 },
new Dictionary<string, int> { ["other"] = 2 },
new Dictionary<string, int> { ["k"] = 5 },
};

await Assert.That(dicts).Count(d => d.ContainsKey("k")).IsEqualTo(2);
}

[Test]
public async Task Count_Set_Items_Reach_IsSubsetOf_On_Inner()
{
var universe = new HashSet<int> { 1, 2, 3, 4, 5 };
var sets = new List<ISet<int>>
{
new HashSet<int> { 1, 2 },
new HashSet<int> { 6 },
new HashSet<int> { 3, 4 },
};

await Assert.That(sets).Count(s => s.IsSubsetOf(universe)).IsEqualTo(2);
}

[Test]
public async Task Count_Specialised_Source_Failure_Message_Mentions_Inner_Expectation()
{
IEnumerable<IEnumerable<int>> listOfLists = new List<List<int>>
{
new() { 1 },
new() { 1 },
};

// Expect 5 items with inner-count==3 → there are 0; ensure failure message
// surfaces the specialised inner expectation rather than just "received 0".
var ex = await Assert.That(async () =>
await Assert.That(listOfLists).Count(l => l.Count().IsEqualTo(3)).IsEqualTo(5))
.Throws<AssertionException>();

// The chained expression should include `.Count(...)` per-item filter.
await Assert.That(ex.Message).Contains(".Count(l => l.Count().IsEqualTo(3))");
}
}
62 changes: 47 additions & 15 deletions TUnit.Assertions/Conditions/CollectionCountSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,38 @@ public class CollectionCountSource<TCollection, TItem>
where TCollection : IEnumerable<TItem>
{
private readonly AssertionContext<TCollection> _collectionContext;
private readonly Func<IAssertionSource<TItem>, Assertion<TItem>?>? _assertion;
private readonly Func<TItem, int, IAssertion?>? _itemAssertionFactory;

public CollectionCountSource(
AssertionContext<TCollection> collectionContext,
Func<IAssertionSource<TItem>, Assertion<TItem>?>? assertion)
: this(collectionContext, WrapWithValueAssertion(assertion))
{
}

/// <summary>
/// Internal constructor that accepts a per-item assertion factory directly.
/// Used by specialised <c>Count(itemAssertion)</c> overloads that supply
/// item-shape-specific assertion sources (e.g. collection, dictionary, set)
/// instead of the generic <see cref="ValueAssertion{TItem}"/>.
/// </summary>
internal CollectionCountSource(
AssertionContext<TCollection> collectionContext,
Func<TItem, int, IAssertion?>? itemAssertionFactory)
{
_collectionContext = collectionContext;
_assertion = assertion;
_itemAssertionFactory = itemAssertionFactory;
}

internal static Func<TItem, int, IAssertion?>? WrapWithValueAssertion(
Func<IAssertionSource<TItem>, Assertion<TItem>?>? assertion)
{
if (assertion is null)
{
return null;
}

return (item, index) => assertion(new ValueAssertion<TItem>(item, $"item[{index}]"));
}

/// <summary>
Expand All @@ -32,7 +56,7 @@ public CollectionCountEqualsAssertion<TCollection, TItem> IsEqualTo(
{
_collectionContext.ExpressionBuilder.Append($".IsEqualTo({expression})");
return new CollectionCountEqualsAssertion<TCollection, TItem>(
_collectionContext, _assertion, expected, CountComparison.Equal);
_collectionContext, _itemAssertionFactory, expected, CountComparison.Equal);
}

/// <summary>
Expand All @@ -45,7 +69,7 @@ public CollectionCountEqualsAssertion<TCollection, TItem> IsNotEqualTo(
{
_collectionContext.ExpressionBuilder.Append($".IsNotEqualTo({expression})");
return new CollectionCountEqualsAssertion<TCollection, TItem>(
_collectionContext, _assertion, expected, CountComparison.NotEqual);
_collectionContext, _itemAssertionFactory, expected, CountComparison.NotEqual);
}

/// <summary>
Expand All @@ -58,7 +82,7 @@ public CollectionCountEqualsAssertion<TCollection, TItem> IsGreaterThan(
{
_collectionContext.ExpressionBuilder.Append($".IsGreaterThan({expression})");
return new CollectionCountEqualsAssertion<TCollection, TItem>(
_collectionContext, _assertion, expected, CountComparison.GreaterThan);
_collectionContext, _itemAssertionFactory, expected, CountComparison.GreaterThan);
}

/// <summary>
Expand All @@ -71,7 +95,7 @@ public CollectionCountEqualsAssertion<TCollection, TItem> IsGreaterThanOrEqualTo
{
_collectionContext.ExpressionBuilder.Append($".IsGreaterThanOrEqualTo({expression})");
return new CollectionCountEqualsAssertion<TCollection, TItem>(
_collectionContext, _assertion, expected, CountComparison.GreaterThanOrEqual);
_collectionContext, _itemAssertionFactory, expected, CountComparison.GreaterThanOrEqual);
}

/// <summary>
Expand All @@ -84,7 +108,7 @@ public CollectionCountEqualsAssertion<TCollection, TItem> IsLessThan(
{
_collectionContext.ExpressionBuilder.Append($".IsLessThan({expression})");
return new CollectionCountEqualsAssertion<TCollection, TItem>(
_collectionContext, _assertion, expected, CountComparison.LessThan);
_collectionContext, _itemAssertionFactory, expected, CountComparison.LessThan);
}

/// <summary>
Expand All @@ -97,7 +121,7 @@ public CollectionCountEqualsAssertion<TCollection, TItem> IsLessThanOrEqualTo(
{
_collectionContext.ExpressionBuilder.Append($".IsLessThanOrEqualTo({expression})");
return new CollectionCountEqualsAssertion<TCollection, TItem>(
_collectionContext, _assertion, expected, CountComparison.LessThanOrEqual);
_collectionContext, _itemAssertionFactory, expected, CountComparison.LessThanOrEqual);
}

/// <summary>
Expand All @@ -108,7 +132,7 @@ public CollectionCountEqualsAssertion<TCollection, TItem> IsZero()
{
_collectionContext.ExpressionBuilder.Append(".IsZero()");
return new CollectionCountEqualsAssertion<TCollection, TItem>(
_collectionContext, _assertion, 0, CountComparison.Equal);
_collectionContext, _itemAssertionFactory, 0, CountComparison.Equal);
}

/// <summary>
Expand All @@ -119,7 +143,7 @@ public CollectionCountEqualsAssertion<TCollection, TItem> IsPositive()
{
_collectionContext.ExpressionBuilder.Append(".IsPositive()");
return new CollectionCountEqualsAssertion<TCollection, TItem>(
_collectionContext, _assertion, 0, CountComparison.GreaterThan);
_collectionContext, _itemAssertionFactory, 0, CountComparison.GreaterThan);
}
}

Expand All @@ -140,7 +164,7 @@ internal enum CountComparison
public class CollectionCountEqualsAssertion<TCollection, TItem> : CollectionAssertionBase<TCollection, TItem>
where TCollection : IEnumerable<TItem>
{
private readonly Func<IAssertionSource<TItem>, Assertion<TItem>?>? _itemAssertion;
private readonly Func<TItem, int, IAssertion?>? _itemAssertionFactory;
private readonly int _expected;
private readonly CountComparison _comparison;
private int _actualCount;
Expand All @@ -150,9 +174,18 @@ internal CollectionCountEqualsAssertion(
Func<IAssertionSource<TItem>, Assertion<TItem>?>? itemAssertion,
int expected,
CountComparison comparison)
: this(context, CollectionCountSource<TCollection, TItem>.WrapWithValueAssertion(itemAssertion), expected, comparison)
{
}

internal CollectionCountEqualsAssertion(
AssertionContext<TCollection> context,
Func<TItem, int, IAssertion?>? itemAssertionFactory,
int expected,
CountComparison comparison)
: base(context)
{
_itemAssertion = itemAssertion;
_itemAssertionFactory = itemAssertionFactory;
_expected = expected;
_comparison = comparison;
}
Expand All @@ -173,7 +206,7 @@ protected override async Task<AssertionResult> CheckAsync(EvaluationMetadata<TCo
}

// Calculate count
if (_itemAssertion == null)
if (_itemAssertionFactory == null)
{
// Simple count without filtering
_actualCount = value switch
Expand All @@ -190,8 +223,7 @@ protected override async Task<AssertionResult> CheckAsync(EvaluationMetadata<TCo

foreach (var item in value)
{
var itemAssertionSource = new ValueAssertion<TItem>(item, $"item[{index}]");
var resultingAssertion = _itemAssertion(itemAssertionSource);
var resultingAssertion = _itemAssertionFactory(item, index);

if (resultingAssertion != null)
{
Expand Down
Loading
Loading