Background
Both Count(itemAssertion) (closed: #5707, fixed in #5749) and All().Satisfy(itemAssertion) wrap each collection item in a generic ValueAssertion<TItem> and expose only IAssertionSource<TItem> to the user lambda. Specialised assertion methods like HasItemAt, ContainsKey, IsSubsetOf are unreachable inside the lambda when items are themselves collections/dictionaries/sets.
What we did in #5749
PR #5749 added 11 extension overloads on CollectionAssertionBase to expose specialised assertion sources (one per item shape — interface and concrete: IList<T>, List<T>, IReadOnlyList<T>, ISet<T>, HashSet<T>, IReadOnlySet<T>, IDictionary<K,V>, IReadOnlyDictionary<K,V>, Dictionary<K,V>, T[], IEnumerable<T>).
This solved the immediate bug for Count but the pattern has structural problems:
- Same gap exists in
All().Satisfy() — CollectionAllSatisfyAssertion uses new ValueAssertion<TItem>(item, ...) and exposes only IAssertionSource<TItem> to its lambda. Every existing user of All().Satisfy() with collection-shaped items hits the same wall.
- Every new assertion source type (e.g. a hypothetical
SortedSetAssertion, ImmutableListAssertion) requires adding more overloads to Count and to All().Satisfy() and to any future per-item method.
- C# type inference doesn't unify concrete with interface types for generic constraints, so the overload count scales as O(2 × assertion_types) per per-item method.
AssertionExtensions.cs is now ~2,077 lines, a large fraction of which is this expansion.
Proposed factory-based redesign
Have Count(itemAssertion) (and All().Satisfy(itemAssertion)) accept Func<TItem, IAssertion?> — the user's lambda calls Assert.That(item) themselves, which dispatches to the correct typed source automatically:
// User code:
await Assert.That(listOfHashSets).Count(item => Assert.That(item).IsSubsetOf(universe)).IsEqualTo(2);
// ^^^^^^^^^^^^^^^^^^ resolves to SetAssertion<int>
This requires zero per-collection-type overloads in the assertion-extensions layer because Assert.That(TItem) already has the correct dispatch table (it's how top-level assertions get their typed sources today).
Trade-off: slightly more verbose at the call site (item => Assert.That(item).X vs item => item.X). May be acceptable if the savings in the extensions surface is large.
Scope
- Apply same redesign to both
Count and All().Satisfy so the pattern stops spreading
- Migration: keep the per-type overloads as
[Obsolete] for one or two minor versions, point users at the new factory shape
- Once obsolete cycle completes, delete the per-type overloads
Related
Background
Both
Count(itemAssertion)(closed: #5707, fixed in #5749) andAll().Satisfy(itemAssertion)wrap each collection item in a genericValueAssertion<TItem>and expose onlyIAssertionSource<TItem>to the user lambda. Specialised assertion methods likeHasItemAt,ContainsKey,IsSubsetOfare unreachable inside the lambda when items are themselves collections/dictionaries/sets.What we did in #5749
PR #5749 added 11 extension overloads on
CollectionAssertionBaseto expose specialised assertion sources (one per item shape — interface and concrete:IList<T>,List<T>,IReadOnlyList<T>,ISet<T>,HashSet<T>,IReadOnlySet<T>,IDictionary<K,V>,IReadOnlyDictionary<K,V>,Dictionary<K,V>,T[],IEnumerable<T>).This solved the immediate bug for
Countbut the pattern has structural problems:All().Satisfy()—CollectionAllSatisfyAssertionusesnew ValueAssertion<TItem>(item, ...)and exposes onlyIAssertionSource<TItem>to its lambda. Every existing user ofAll().Satisfy()with collection-shaped items hits the same wall.SortedSetAssertion,ImmutableListAssertion) requires adding more overloads toCountand toAll().Satisfy()and to any future per-item method.AssertionExtensions.csis now ~2,077 lines, a large fraction of which is this expansion.Proposed factory-based redesign
Have
Count(itemAssertion)(andAll().Satisfy(itemAssertion)) acceptFunc<TItem, IAssertion?>— the user's lambda callsAssert.That(item)themselves, which dispatches to the correct typed source automatically:This requires zero per-collection-type overloads in the assertion-extensions layer because
Assert.That(TItem)already has the correct dispatch table (it's how top-level assertions get their typed sources today).Trade-off: slightly more verbose at the call site (
item => Assert.That(item).Xvsitem => item.X). May be acceptable if the savings in the extensions surface is large.Scope
CountandAll().Satisfyso the pattern stops spreading[Obsolete]for one or two minor versions, point users at the new factory shapeRelated
CountAll().Satisfy()(still open)