Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using TUnit.Assertions.SourceGenerator.Generators;

namespace TUnit.Assertions.SourceGenerator.Tests;

internal class CollectionShapeAssertionGeneratorTests : TestsBase<CollectionShapeAssertionGenerator>
{
[Test]
public Task Emits_Full_Per_Shape_Assertion_Surface() => RunTest(
Path.Combine(Sourcy.Git.RootDirectory.FullName,
"TUnit.Assertions.SourceGenerator.Tests",
"TestData",
"CollectionShapeAssertionSource.cs"),
async generatedFiles =>
{
// Only Emitter A fires (no Satisfies / CountSpecialised triggers in the input).
await Assert.That(generatedFiles).Count().IsEqualTo(1);

var file = generatedFiles[0];

// Shared pre-work-preserving upcast helper is emitted once.
await Assert.That(file).Contains("AssertionContext<TTo> Upcast<TFrom, TTo>");
await Assert.That(file).Contains("context.PendingPreWork is { } preWork");

// Per-shape signature fidelity reflected from the real symbols: ReadOnlyList.HasItemAt
// carries an IEqualityComparer the IList overload does not.
await Assert.That(file).Contains(
"ReadOnlyListAssertion<TItem>(source.Context).HasItemAt(index, expected, comparer, indexExpression, expectedExpression)");
await Assert.That(file).Contains(
"ListAssertion<TItem>(source.Context).HasItemAt(index, expected, indexExpression, expectedExpression)");

// Set shapes seed via the internal FromContext factory.
await Assert.That(file).Contains("SetAssertion<TItem>.FromContext(source.Context)");

// Concrete shapes upcast their context (List<T> -> IList<T>) so the wrapper's pre-work survives.
await Assert.That(file).Contains(
"Upcast<global::System.Collections.Generic.List<TItem>, global::System.Collections.Generic.IList<TItem>>(source.Context, x => x)");

// Dictionary value shapes preserve dictionary-specific methods and the notnull key constraint.
await Assert.That(file).Contains("DictionaryAssertion<TKey, TValue>(source.Context).ContainsKey");
await Assert.That(file).Contains("where TKey : notnull");
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using TUnit.Assertions.Core;

// Local stand-in for the (internal) TUnit.Assertions.Attributes.GenerateCollectionShapeAssertionsAttribute.
// The generator matches the trigger by metadata name, so a same-named source declaration drives it without
// needing friend access to the real internal attribute (which strong-naming would otherwise require). The
// real attribute is invisible here (internal, no InternalsVisibleTo), so there is no accessible conflict.
namespace TUnit.Assertions.Attributes
{
[System.AttributeUsage(System.AttributeTargets.Class)]
internal sealed class GenerateCollectionShapeAssertionsAttribute : System.Attribute;
}

namespace TUnit.Assertions.SourceGenerator.Tests.TestData
{
// Minimal arity-1 wrapper source used to exercise CollectionShapeAssertionGenerator (Emitter A).
// The generator reflects the real shape-assertion-source method surface from the referenced
// TUnit.Assertions assembly, so per-shape signature fidelity (e.g. the ReadOnlyList HasItemAt
// comparer parameter that IList lacks) is asserted against the actual symbols. Only the single
// type parameter and the public Context property are required by the generator.
[TUnit.Assertions.Attributes.GenerateCollectionShapeAssertions]
public sealed class CollectionShapeAssertionSource<T>
{
public AssertionContext<T> Context { get; } = null!;
}
}

Large diffs are not rendered by default.

149 changes: 149 additions & 0 deletions TUnit.Assertions.Tests/DictionaryCollectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,155 @@ public async Task IDictionary_ContainsKey_And_Value_IsEqualTo()
await Assert.That(dictionary).ContainsKey("Key").And.Value.IsEqualTo(1234L);
}

// ── Collection-typed value drill-in (issue #6185 follow-up) ───────────────────────
// When the dictionary value is itself a collection, .Value must expose the collection
// surface (Count/Contains/IsEmpty/…) rather than binding to LINQ's Enumerable.Count (CS0411).

[Test]
public async Task Dictionary_ContainsKey_And_Value_Count_When_Value_Is_Collection()
{
IDictionary<string, IEnumerable<int>> dictionary = new Dictionary<string, IEnumerable<int>>
{
["Key"] = new[] { 1, 2 }
};

await Assert.That(dictionary).ContainsKey("Key").And.Value.Count().IsEqualTo(2);
}

[Test]
public async Task IReadOnlyDictionary_ContainsKey_And_Value_Count_When_Value_Is_Collection()
{
IReadOnlyDictionary<string, IEnumerable<string>> dictionary = new Dictionary<string, IEnumerable<string>>
{
["Key"] = new[] { "a", "b", "c" }
};

await Assert.That(dictionary).ContainsKey("Key").And.Value.Count().IsGreaterThan(2);
}

[Test]
public async Task Dictionary_ContainsKey_And_Value_Collection_Contains_And_IsNotEmpty()
{
IDictionary<string, IEnumerable<int>> dictionary = new Dictionary<string, IEnumerable<int>>
{
["Key"] = new[] { 10, 20, 30 }
};

await Assert.That(dictionary).ContainsKey("Key").And.Value.Contains(20);
await Assert.That(dictionary).ContainsKey("Key").And.Value.IsNotEmpty();
}

[Test]
public async Task Dictionary_ContainsKey_And_Value_Collection_Count_Fails_With_Count_Message()
{
IDictionary<string, IEnumerable<int>> dictionary = new Dictionary<string, IEnumerable<int>>
{
["Key"] = new[] { 1, 2 }
};

var exception = await Assert.ThrowsAsync<TUnit.Assertions.Exceptions.AssertionException>(
async () => await Assert.That(dictionary).ContainsKey("Key").And.Value.Count().IsEqualTo(5));

await Assert.That(exception.Message).Contains("count");
}

[Test]
public async Task Dictionary_ContainsKey_And_Value_Collection_Fails_When_Key_Missing()
{
IDictionary<string, IEnumerable<int>> dictionary = new Dictionary<string, IEnumerable<int>>
{
["Key"] = new[] { 1, 2 }
};

// The ContainsKey pre-work still runs first, so a missing key fails with the
// standard "contain key" message rather than reading the (absent) collection.
var exception = await Assert.ThrowsAsync<TUnit.Assertions.Exceptions.AssertionException>(
async () => await Assert.That(dictionary).ContainsKey("Missing").And.Value.Count().IsEqualTo(2));

await Assert.That(exception.Message).Contains("contain key");
}

[Test]
public async Task Dictionary_ContainsKey_And_Value_List_HasItemAt()
{
IDictionary<string, IList<int>> dictionary = new Dictionary<string, IList<int>>
{
["Key"] = new List<int> { 10, 20, 30 }
};

// List-specific surface (HasItemAt / ItemAt) is preserved, not degraded to a bare enumerable.
await Assert.That(dictionary).ContainsKey("Key").And.Value.HasItemAt(1, 20);
await Assert.That(dictionary).ContainsKey("Key").And.Value.ItemAt(2).IsEqualTo(30);
}

[Test]
public async Task Dictionary_ContainsKey_And_Value_Set_IsSubsetOf()
{
IDictionary<string, ISet<int>> dictionary = new Dictionary<string, ISet<int>>
{
["Key"] = new HashSet<int> { 1, 2 }
};

await Assert.That(dictionary).ContainsKey("Key").And.Value.IsSubsetOf(new[] { 1, 2, 3 });
}

[Test]
public async Task Dictionary_ContainsKey_And_Value_NestedDictionary_ContainsKey()
{
IDictionary<string, IDictionary<string, int>> dictionary = new Dictionary<string, IDictionary<string, int>>
{
["Outer"] = new Dictionary<string, int> { ["Inner"] = 42 }
};

await Assert.That(dictionary).ContainsKey("Outer").And.Value.ContainsKey("Inner");
await Assert.That(dictionary).ContainsKey("Outer").And.Value.ContainsKeyWithValue("Inner", 42);
}

[Test]
public async Task Dictionary_ContainsKey_And_Value_Array_Count()
{
IDictionary<string, int[]> dictionary = new Dictionary<string, int[]>
{
["Key"] = new[] { 1, 2, 3 }
};

await Assert.That(dictionary).ContainsKey("Key").And.Value.Count().IsEqualTo(3);
}

[Test]
public async Task Dictionary_ContainsKey_And_Value_ConcreteList_Count_And_MissingKey()
{
IDictionary<string, List<int>> dictionary = new Dictionary<string, List<int>>
{
["Key"] = new List<int> { 1, 2 }
};

await Assert.That(dictionary).ContainsKey("Key").And.Value.Count().IsEqualTo(2);

// Concrete List<T> uses the upcast seed; the ContainsKey pre-work must still run first.
var exception = await Assert.ThrowsAsync<TUnit.Assertions.Exceptions.AssertionException>(
async () => await Assert.That(dictionary).ContainsKey("Missing").And.Value.Count().IsEqualTo(2));

await Assert.That(exception.Message).Contains("contain key");
}

[Test]
public async Task Dictionary_ContainsKey_And_Value_ConcreteDictionary_ContainsKey_And_MissingKey()
{
IDictionary<string, Dictionary<string, int>> dictionary = new Dictionary<string, Dictionary<string, int>>
{
["Outer"] = new Dictionary<string, int> { ["Inner"] = 7 }
};

await Assert.That(dictionary).ContainsKey("Outer").And.Value.ContainsKey("Inner");

// Concrete Dictionary<K,V> uses the upcast seed; the ContainsKey pre-work must still run first.
var exception = await Assert.ThrowsAsync<TUnit.Assertions.Exceptions.AssertionException>(
async () => await Assert.That(dictionary).ContainsKey("Missing").And.Value.ContainsKey("Inner"));

await Assert.That(exception.Message).Contains("contain key");
}

[Test]
public async Task Dictionary_IsNotEmpty_Preserves_Dictionary_Continuation()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System;

namespace TUnit.Assertions.Attributes;

/// <summary>
/// Marks a generic, arity-1 assertion source (one whose single type parameter is the wrapped value type and
/// which exposes a public <c>AssertionContext&lt;T&gt; Context</c>) as wanting the full collection-shape-specific
/// assertion surface generated as forwarding extension methods — one overload set per collection shape
/// (IEnumerable / IReadOnlyList / IList / List / array / set / dictionary / …).
///
/// <para>
/// The generator (<c>CollectionShapeAssertionGenerator</c>) reflects the real public method surface of each
/// shape's assertion source, so per-shape signature differences (e.g. the <c>IEqualityComparer</c> parameter
/// on <c>ReadOnlyList.HasItemAt</c> that <c>IList.HasItemAt</c> lacks) are always correct and new methods are
/// picked up automatically. See issue #6185.
/// </para>
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
internal sealed class GenerateCollectionShapeAssertionsAttribute : Attribute
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System;

namespace TUnit.Assertions.Attributes;

/// <summary>
/// Marks the shared <c>Count(itemAssertion)</c> specialisation helper so the generator emits one
/// per-collection-shape overload of <c>Count</c> whose item-assertion lambda receives the most specific
/// assertion source for each item shape. Replaces the hand-written per-shape block in
/// <c>AssertionExtensions.cs</c> (#5707). See issue #6185.
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
internal sealed class GenerateCollectionShapeCountOverloadsAttribute : Attribute
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System;

namespace TUnit.Assertions.Attributes;

/// <summary>
/// Marks the core generic <c>Satisfies&lt;TSource&gt;</c> forwarding method so the generator emits one
/// per-collection-shape overload that binds the most specific assertion source (CollectionAssertion /
/// ListAssertion / SetAssertion / DictionaryAssertion / …) for the user lambda. Replaces the hand-written
/// per-shape block in <c>CollectionItemSatisfiesExtensions.cs</c>. See issue #6185.
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
internal sealed class GenerateCollectionShapeSatisfiesOverloadsAttribute : Attribute
{
}
1 change: 1 addition & 0 deletions TUnit.Assertions/Conditions/DictionaryValueSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ namespace TUnit.Assertions.Conditions;
/// Created via <c>ContainsKey(key).And.Value</c>.
/// </summary>
/// <typeparam name="TValue">The dictionary value type.</typeparam>
[global::TUnit.Assertions.Attributes.GenerateCollectionShapeAssertions]
public sealed class DictionaryValueSource<TValue> : ValueAssertion<TValue>
{
internal DictionaryValueSource(AssertionContext<TValue> context)
Expand Down
1 change: 1 addition & 0 deletions TUnit.Assertions/Core/IItemSatisfiesSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ namespace TUnit.Assertions.Core;
/// <typeparam name="TResult">The selector-specific assertion result type.</typeparam>
public interface IItemSatisfiesSource<TItem, TResult> : IAssertionSource<TItem>
{
[global::TUnit.Assertions.Attributes.GenerateCollectionShapeSatisfiesOverloads]
TResult Satisfies<TSource>(Func<TSource, IAssertion?> assertion, string? expression = null)
where TSource : IAssertionSourceFor<TItem, TSource>;
}
Expand Down
Loading
Loading