diff --git a/TUnit.Assertions.Tests/TypeInferenceTests.cs b/TUnit.Assertions.Tests/TypeInferenceTests.cs index b4e0d9b5b7..fcfdd06a10 100644 --- a/TUnit.Assertions.Tests/TypeInferenceTests.cs +++ b/TUnit.Assertions.Tests/TypeInferenceTests.cs @@ -58,4 +58,30 @@ await Assert.That(enumerable) // Just want to surface any compiler errors if we knock out type inference } } + + [Test] + public async Task HasSingleItemReturnsSingleItem() + { + // HasSingleItem can be awaited to get the single item + // Or chained with .And to continue collection assertions + IEnumerable enumerable = [42]; + + try + { + // Test 1: Await to get the single item + var item = await Assert.That(enumerable).HasSingleItem(); + await Assert.That(item).IsEqualTo(42); + + // Test 2: Chain with .And for collection assertions + await Assert.That(enumerable) + .HasSingleItem() + .And + .ContainsOnly(x => x == 42); + } + catch + { + // Don't care for assertion failures + // Just want to surface any compiler errors if we knock out type inference + } + } } diff --git a/TUnit.Assertions/Conditions/CollectionAssertions.cs b/TUnit.Assertions/Conditions/CollectionAssertions.cs index c3c39802df..18d95832cb 100644 --- a/TUnit.Assertions/Conditions/CollectionAssertions.cs +++ b/TUnit.Assertions/Conditions/CollectionAssertions.cs @@ -479,17 +479,20 @@ protected override Task CheckAsync(EvaluationMetadata /// Asserts that a collection contains exactly one item. +/// When awaited, returns the single item for further assertions. /// -public class HasSingleItemAssertion : Assertion - where TValue : IEnumerable +public class HasSingleItemAssertion : Assertion + where TCollection : IEnumerable { + private TItem? _singleItem; + public HasSingleItemAssertion( - AssertionContext context) + AssertionContext context) : base(context) { } - protected override Task CheckAsync(EvaluationMetadata metadata) + protected override Task CheckAsync(EvaluationMetadata metadata) { var value = metadata.Value; var exception = metadata.Exception; @@ -512,7 +515,10 @@ protected override Task CheckAsync(EvaluationMetadata m return Task.FromResult(AssertionResult.Failed("collection is empty")); } - // First item exists, check if there's a second + // Store the single item + _singleItem = enumerator.Current; + + // Check if there's a second item if (enumerator.MoveNext()) { return Task.FromResult(AssertionResult.Failed("collection has more than one item")); @@ -527,6 +533,24 @@ protected override Task CheckAsync(EvaluationMetadata m } protected override string GetExpectation() => "to have exactly one item"; + + /// + /// Enables await syntax that returns the single item. + /// This allows both chaining (.And) and item capture (await). + /// + public new System.Runtime.CompilerServices.TaskAwaiter GetAwaiter() + { + return ExecuteAndReturnItemAsync().GetAwaiter(); + } + + private async Task ExecuteAndReturnItemAsync() + { + // Execute the assertion (will throw if not exactly one item) + await AssertAsync(); + + // Return the single item + return _singleItem!; + } } /// diff --git a/TUnit.Assertions/Extensions/AssertionExtensions.cs b/TUnit.Assertions/Extensions/AssertionExtensions.cs index 794176e0a1..5d9803f886 100644 --- a/TUnit.Assertions/Extensions/AssertionExtensions.cs +++ b/TUnit.Assertions/Extensions/AssertionExtensions.cs @@ -948,13 +948,38 @@ public static CollectionAnyAssertion Any /// /// Asserts that the collection contains exactly one item. + /// When awaited, returns the single item for further assertions. /// - public static HasSingleItemAssertion HasSingleItem( - this IAssertionSource source) - where TValue : IEnumerable + public static HasSingleItemAssertion HasSingleItem( + this IAssertionSource source) + where TCollection : IEnumerable + { + source.Context.ExpressionBuilder.Append(".HasSingleItem()"); + return new HasSingleItemAssertion(source.Context); + } + + /// + /// Asserts that the collection contains exactly one item. + /// When awaited, returns the single item for further assertions. + /// Specific overload for IEnumerable to fix C# type inference. + /// + public static HasSingleItemAssertion, TItem> HasSingleItem( + this IAssertionSource> source) + { + source.Context.ExpressionBuilder.Append(".HasSingleItem()"); + return new HasSingleItemAssertion, TItem>(source.Context); + } + + /// + /// Asserts that the collection contains exactly one item. + /// When awaited, returns the single item for further assertions. + /// Specific overload for IReadOnlyList to fix C# type inference. + /// + public static HasSingleItemAssertion, TItem> HasSingleItem( + this IAssertionSource> source) { source.Context.ExpressionBuilder.Append(".HasSingleItem()"); - return new HasSingleItemAssertion(source.Context); + return new HasSingleItemAssertion, TItem>(source.Context); } /// 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 acf25fdd17..cb1a3de129 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 @@ -853,11 +853,12 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } - public class HasSingleItemAssertion : . - where TValue : .IEnumerable + public class HasSingleItemAssertion : . + where TCollection : . { - public HasSingleItemAssertion(. context) { } - protected override .<.> CheckAsync(. metadata) { } + public HasSingleItemAssertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + public new . GetAwaiter() { } protected override string GetExpectation() { } } public static class HttpStatusCodeAssertionExtensions @@ -1692,8 +1693,10 @@ namespace .Extensions where TEnum : struct, { } public static ..HasSameValueAsAssertion HasSameValueAs(this . source, otherEnumValue, [.("otherEnumValue")] string? expression = null) where TEnum : struct, { } - public static . HasSingleItem(this . source) - where TValue : .IEnumerable { } + public static .<., TItem> HasSingleItem(this .<.> source) { } + public static .<., TItem> HasSingleItem(this .<.> source) { } + public static . HasSingleItem(this . source) + where TCollection : . { } public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { } public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { } public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt index c50907409b..c8b79cfe23 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -853,11 +853,12 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } - public class HasSingleItemAssertion : . - where TValue : .IEnumerable + public class HasSingleItemAssertion : . + where TCollection : . { - public HasSingleItemAssertion(. context) { } - protected override .<.> CheckAsync(. metadata) { } + public HasSingleItemAssertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + public new . GetAwaiter() { } protected override string GetExpectation() { } } public static class HttpStatusCodeAssertionExtensions @@ -1692,8 +1693,10 @@ namespace .Extensions where TEnum : struct, { } public static ..HasSameValueAsAssertion HasSameValueAs(this . source, otherEnumValue, [.("otherEnumValue")] string? expression = null) where TEnum : struct, { } - public static . HasSingleItem(this . source) - where TValue : .IEnumerable { } + public static .<., TItem> HasSingleItem(this .<.> source) { } + public static .<., TItem> HasSingleItem(this .<.> source) { } + public static . HasSingleItem(this . source) + where TCollection : . { } public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { } public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { } public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 9393d48762..14c38b422c 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -853,11 +853,12 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } - public class HasSingleItemAssertion : . - where TValue : .IEnumerable + public class HasSingleItemAssertion : . + where TCollection : . { - public HasSingleItemAssertion(. context) { } - protected override .<.> CheckAsync(. metadata) { } + public HasSingleItemAssertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + public new . GetAwaiter() { } protected override string GetExpectation() { } } public static class HttpStatusCodeAssertionExtensions @@ -1692,8 +1693,10 @@ namespace .Extensions where TEnum : struct, { } public static ..HasSameValueAsAssertion HasSameValueAs(this . source, otherEnumValue, [.("otherEnumValue")] string? expression = null) where TEnum : struct, { } - public static . HasSingleItem(this . source) - where TValue : .IEnumerable { } + public static .<., TItem> HasSingleItem(this .<.> source) { } + public static .<., TItem> HasSingleItem(this .<.> source) { } + public static . HasSingleItem(this . source) + where TCollection : . { } public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { } public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { } public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt index 95ce6230ae..f7ae10163f 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -816,11 +816,12 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } - public class HasSingleItemAssertion : . - where TValue : .IEnumerable + public class HasSingleItemAssertion : . + where TCollection : . { - public HasSingleItemAssertion(. context) { } - protected override .<.> CheckAsync(. metadata) { } + public HasSingleItemAssertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + public new . GetAwaiter() { } protected override string GetExpectation() { } } public static class HttpStatusCodeAssertionExtensions @@ -1597,8 +1598,10 @@ namespace .Extensions where TEnum : struct, { } public static ..HasSameValueAsAssertion HasSameValueAs(this . source, otherEnumValue, [.("otherEnumValue")] string? expression = null) where TEnum : struct, { } - public static . HasSingleItem(this . source) - where TValue : .IEnumerable { } + public static .<., TItem> HasSingleItem(this .<.> source) { } + public static .<., TItem> HasSingleItem(this .<.> source) { } + public static . HasSingleItem(this . source) + where TCollection : . { } public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { } public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { } public static .<> IsAfterOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } diff --git a/TUnit.TestProject/Tests.cs b/TUnit.TestProject/Tests.cs index 45b371e878..7ee28ca7d5 100644 --- a/TUnit.TestProject/Tests.cs +++ b/TUnit.TestProject/Tests.cs @@ -266,9 +266,7 @@ public async Task Single() { var list = new List { 1 }; var item = await Assert.That(list).HasSingleItem(); - await Assert.That(list).HasSingleItem(); - // Fixed: HasSingleItem returns the collection, not the single item - // await Assert.That(item).IsEqualTo(1); + await Assert.That(item).IsEqualTo(1); } [Test]