diff --git a/Directory.Packages.props b/Directory.Packages.props index 1573d01cff..9164feaabb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -81,9 +81,9 @@ - - - + + + diff --git a/TUnit.Assertions.SourceGenerator/Generators/AssertionMethodGenerator.cs b/TUnit.Assertions.SourceGenerator/Generators/AssertionMethodGenerator.cs index d2c023ea00..1120d2c753 100644 --- a/TUnit.Assertions.SourceGenerator/Generators/AssertionMethodGenerator.cs +++ b/TUnit.Assertions.SourceGenerator/Generators/AssertionMethodGenerator.cs @@ -699,7 +699,28 @@ private static void GenerateAssertConditionClassForMethod(SourceProductionContex sourceBuilder.AppendLine(); } - if (!attributeData.TargetType.IsValueType) + // Check if the method's first parameter accepts null values + // For static methods like string.IsNullOrEmpty(string? value), the first parameter + // is the value being asserted. If it's marked as nullable (NullableAnnotation.Annotated), + // we should skip the null check and let the method handle null values. + var shouldGenerateNullCheck = !attributeData.TargetType.IsValueType; + if (shouldGenerateNullCheck && staticMethod.Parameters.Length > 0) + { + var firstParameter = staticMethod.Parameters[0]; + // Skip null check if the parameter explicitly accepts null (e.g., string? value) + if (firstParameter.NullableAnnotation == NullableAnnotation.Annotated) + { + shouldGenerateNullCheck = false; + } + // For backwards compatibility with .NET Framework where NullableAnnotation might not be set, + // also check for well-known methods that accept null by design + else if (methodName == "IsNullOrEmpty" || methodName == "IsNullOrWhiteSpace") + { + shouldGenerateNullCheck = false; + } + } + + if (shouldGenerateNullCheck) { sourceBuilder.AppendLine(" if (actualValue is null)"); sourceBuilder.AppendLine(" {"); diff --git a/TUnit.Assertions.Tests/Bugs/Issue3422Tests.cs b/TUnit.Assertions.Tests/Bugs/Issue3422Tests.cs new file mode 100644 index 0000000000..78e5aa843c --- /dev/null +++ b/TUnit.Assertions.Tests/Bugs/Issue3422Tests.cs @@ -0,0 +1,50 @@ +namespace TUnit.Assertions.Tests.Bugs; + +/// +/// Tests for issue #3422: CS8620 and CS8619 warnings with object? parameters +/// https://github.com/thomhurst/TUnit/issues/3422 +/// +public class Issue3422Tests +{ + [Test] + [Arguments(null, null)] + [Arguments("test", "test")] + [Arguments(123, 123)] + public async Task Assert_That_With_Object_Nullable_Parameters_Should_Not_Cause_Warnings(object? value, object? expected) + { + // This should not produce CS8620 or CS8619 warnings + await Assert.That(value).IsEqualTo(expected); + } + + [Test] + public async Task Assert_That_Throws_With_Null_Object_Parameter_Should_Not_Cause_Warnings() + { + // This test reproduces the second scenario from issue #3422 + object? data = null; + await Assert.That(() => MethodToTest(data!)).Throws(); + + Task MethodToTest(object value) + { + if (value is null) + throw new ArgumentNullException(nameof(value)); + return Task.FromResult(new object()); + } + } + + [Test] + public async Task Assert_That_With_Non_Null_Object_Parameter_Should_Not_Throw() + { + // Test that when data is not null, the method executes successfully without warnings + object? data = "test"; + var result = await MethodToTest(data!); + + await Assert.That(result).IsNotNull(); + + async Task MethodToTest(object value) + { + if (value is null) + throw new ArgumentNullException(nameof(value)); + return await Task.FromResult(new object()); + } + } +} diff --git a/TUnit.Assertions.Tests/DateTimeAssertionTests.cs b/TUnit.Assertions.Tests/DateTimeAssertionTests.cs index 0a73e2653e..d87c8a641c 100644 --- a/TUnit.Assertions.Tests/DateTimeAssertionTests.cs +++ b/TUnit.Assertions.Tests/DateTimeAssertionTests.cs @@ -210,4 +210,58 @@ public async Task Test_DateTime_IsNotDaylightSavingTime() await Assert.That(winterDate).IsNotDaylightSavingTime(); } } + + [Test] + public async Task Test_DateTime_IsAfter() + { + var dateTimeBefore = new DateTime(2024, 1, 1, 12, 0, 0); + var dateTimeAfter = new DateTime(2024, 1, 2, 12, 0, 0); + await Assert.That(dateTimeAfter).IsAfter(dateTimeBefore); + } + + [Test] + public async Task Test_DateTime_IsAfter_SameTime_Fails() + { + var dateTime = new DateTime(2024, 1, 1, 12, 0, 0); + await Assert.That(async () => await Assert.That(dateTime).IsAfter(dateTime)) + .ThrowsException(); + } + + [Test] + public async Task Test_DateTime_IsBefore() + { + var dateTimeBefore = new DateTime(2024, 1, 1, 12, 0, 0); + var dateTimeAfter = new DateTime(2024, 1, 2, 12, 0, 0); + await Assert.That(dateTimeBefore).IsBefore(dateTimeAfter); + } + + [Test] + public async Task Test_DateTime_IsBefore_SameTime_Fails() + { + var dateTime = new DateTime(2024, 1, 1, 12, 0, 0); + await Assert.That(async () => await Assert.That(dateTime).IsBefore(dateTime)) + .ThrowsException(); + } + + [Test] + public async Task Test_DateTime_IsBeforeOrEqualTo() + { + var dateTimeBefore = new DateTime(2024, 1, 1, 12, 0, 0); + var dateTimeAfter = new DateTime(2024, 1, 2, 12, 0, 0); + await Assert.That(dateTimeBefore).IsBeforeOrEqualTo(dateTimeAfter); + } + + [Test] + public async Task Test_DateTime_IsBeforeOrEqualTo_SameTime() + { + var dateTime = new DateTime(2024, 1, 1, 12, 0, 0); + await Assert.That(dateTime).IsBeforeOrEqualTo(dateTime); + } + + [Test] + public async Task Test_DateTime_IsAfterOrEqualTo_SameTime() + { + var dateTime = new DateTime(2024, 1, 1, 12, 0, 0); + await Assert.That(dateTime).IsAfterOrEqualTo(dateTime); + } } diff --git a/TUnit.Assertions.Tests/DateTimeOffsetAssertionTests.cs b/TUnit.Assertions.Tests/DateTimeOffsetAssertionTests.cs index ea2c15c005..b8a8434c39 100644 --- a/TUnit.Assertions.Tests/DateTimeOffsetAssertionTests.cs +++ b/TUnit.Assertions.Tests/DateTimeOffsetAssertionTests.cs @@ -189,4 +189,66 @@ public async Task Test_DateTimeOffset_IsOnWeekday_Wednesday() var wednesday = new DateTimeOffset(daysUntilWednesday == 0 ? today : today.AddDays(daysUntilWednesday)); await Assert.That(wednesday).IsOnWeekday(); } + + [Test] + public async Task Test_DateTimeOffset_IsAfter() + { + var before = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero); + var after = new DateTimeOffset(2024, 1, 2, 12, 0, 0, TimeSpan.Zero); + await Assert.That(after).IsAfter(before); + } + + [Test] + public async Task Test_DateTimeOffset_IsAfter_SameTime_Fails() + { + var dateTimeOffset = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero); + await Assert.That(async () => await Assert.That(dateTimeOffset).IsAfter(dateTimeOffset)) + .ThrowsException(); + } + + [Test] + public async Task Test_DateTimeOffset_IsBefore() + { + var before = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero); + var after = new DateTimeOffset(2024, 1, 2, 12, 0, 0, TimeSpan.Zero); + await Assert.That(before).IsBefore(after); + } + + [Test] + public async Task Test_DateTimeOffset_IsBefore_SameTime_Fails() + { + var dateTimeOffset = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero); + await Assert.That(async () => await Assert.That(dateTimeOffset).IsBefore(dateTimeOffset)) + .ThrowsException(); + } + + [Test] + public async Task Test_DateTimeOffset_IsAfterOrEqualTo() + { + var before = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero); + var after = new DateTimeOffset(2024, 1, 2, 12, 0, 0, TimeSpan.Zero); + await Assert.That(after).IsAfterOrEqualTo(before); + } + + [Test] + public async Task Test_DateTimeOffset_IsAfterOrEqualTo_SameTime() + { + var dateTimeOffset = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero); + await Assert.That(dateTimeOffset).IsAfterOrEqualTo(dateTimeOffset); + } + + [Test] + public async Task Test_DateTimeOffset_IsBeforeOrEqualTo() + { + var before = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero); + var after = new DateTimeOffset(2024, 1, 2, 12, 0, 0, TimeSpan.Zero); + await Assert.That(before).IsBeforeOrEqualTo(after); + } + + [Test] + public async Task Test_DateTimeOffset_IsBeforeOrEqualTo_SameTime() + { + var dateTimeOffset = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero); + await Assert.That(dateTimeOffset).IsBeforeOrEqualTo(dateTimeOffset); + } } diff --git a/TUnit.Assertions.Tests/ParseAssertionTests.cs b/TUnit.Assertions.Tests/ParseAssertionTests.cs index e85339455d..f06e97eb73 100644 --- a/TUnit.Assertions.Tests/ParseAssertionTests.cs +++ b/TUnit.Assertions.Tests/ParseAssertionTests.cs @@ -54,4 +54,153 @@ await Assert.That("true") .WhenParsedInto() .IsTrue(); } + + // Tests for issue #3425: Assertions before conversion are not checked + [Test] + public async Task WhenParsedInto_WithAnd_PreviousAssertion_ShouldFail() + { + // HasLength(4) should fail because "123" has length 3 + var exception = await Assert.That(async () => + { + var sut = "123"; + await Assert.That(sut) + .HasLength(4) + .And + .WhenParsedInto() + .IsEqualTo(123); + }).ThrowsException().And.HasMessageContaining("HasLength(4)"); + } + + [Test] + public async Task WhenParsedInto_WithAnd_PreviousAssertion_ShouldPass() + { + // Both assertions should pass + var sut = "123"; + await Assert.That(sut) + .HasLength(3) + .And + .WhenParsedInto() + .IsEqualTo(123); + } + + [Test] + public async Task WhenParsedInto_WithAnd_MultiplePreviousAssertions_ShouldFail() + { + // IsNotEmpty should pass, but HasLength(4) should fail + var exception = await Assert.That(async () => + { + var sut = "123"; + await Assert.That(sut) + .IsNotEmpty() + .And + .HasLength(4) + .And + .WhenParsedInto() + .IsGreaterThan(100); + }).ThrowsException().And.HasMessageContaining("HasLength(4)"); + } + + [Test] + public async Task WhenParsedInto_WithAnd_ChainingAfterParse() + { + // Previous assertion should be checked before parsing + var sut = "100"; + await Assert.That(sut) + .HasLength(3) + .And + .WhenParsedInto() + .IsGreaterThan(50) + .And + .IsLessThan(200); + } + + [Test] + public async Task WhenParsedInto_WithAnd_FirstAssertionInChainFails() + { + // First assertion should fail, never reach parsing + var exception = await Assert.That(async () => + { + var sut = "123"; + await Assert.That(sut) + .StartsWith("4") + .And + .WhenParsedInto() + .IsEqualTo(123); + }).ThrowsException().And.HasMessageContaining("StartsWith"); + } + + [Test] + public async Task WhenParsedInto_WithAnd_ParsedAssertionFails() + { + // Previous assertion passes, but parsed assertion fails + var exception = await Assert.That(async () => + { + var sut = "123"; + await Assert.That(sut) + .HasLength(3) + .And + .WhenParsedInto() + .IsEqualTo(456); + }).ThrowsException().And.HasMessageContaining("IsEqualTo"); + } + + [Test] + public async Task WhenParsedInto_AssertMultiple_AllAssertionsChecked() + { + // In Assert.Multiple, both failing assertions should be captured + var exception = await Assert.That(async () => + { + using (Assert.Multiple()) + { + var sut = "123"; + await Assert.That(sut) + .HasLength(4) + .And + .WhenParsedInto() + .IsEqualTo(456); + } + }).ThrowsException(); + + // Should have recorded the HasLength failure + await Assert.That(exception.Message).Contains("HasLength"); + } + + [Test] + public async Task WhenParsedInto_ComplexChain_AllChecked() + { + // Complex chain: string assertions, parse, int assertions + var sut = "1234"; + await Assert.That(sut) + .HasLength(4) + .And + .StartsWith("1") + .And + .EndsWith("4") + .And + .WhenParsedInto() + .IsGreaterThan(1000) + .And + .IsLessThan(2000) + .And + .IsBetween(1200, 1300); + } + + [Test] + public async Task WhenParsedInto_ComplexChain_MiddleStringAssertionFails() + { + // Should fail at EndsWith before reaching parsing + var exception = await Assert.That(async () => + { + var sut = "1234"; + await Assert.That(sut) + .HasLength(4) + .And + .StartsWith("1") + .And + .EndsWith("5") + .And + .WhenParsedInto() + .IsGreaterThan(1000); + }).ThrowsException().And.HasMessageContaining("EndsWith"); + } } diff --git a/TUnit.Assertions.Tests/StringNullabilityAssertionTests.cs b/TUnit.Assertions.Tests/StringNullabilityAssertionTests.cs new file mode 100644 index 0000000000..45c7b48969 --- /dev/null +++ b/TUnit.Assertions.Tests/StringNullabilityAssertionTests.cs @@ -0,0 +1,91 @@ +using TUnit.Assertions.Extensions; + +namespace TUnit.Assertions.Tests; + +/// +/// Tests for string assertions that accept null values (IsNullOrEmpty, IsNullOrWhiteSpace) +/// +public class StringNullabilityAssertionTests +{ + [Test] + public async Task IsNullOrEmpty_WithNullString_Passes() + { + string? nullString = null; + await Assert.That(nullString).IsNullOrEmpty(); + } + + [Test] + public async Task IsNullOrEmpty_WithEmptyString_Passes() + { + var emptyString = ""; + await Assert.That(emptyString).IsNullOrEmpty(); + } + + [Test] + public async Task IsNullOrEmpty_WithNonEmptyString_Fails() + { + var value = "Hello"; + await Assert.That(async () => await Assert.That(value).IsNullOrEmpty()) + .Throws(); + } + + [Test] + public async Task IsNullOrEmpty_WithWhitespace_Fails() + { + var value = " "; + await Assert.That(async () => await Assert.That(value).IsNullOrEmpty()) + .Throws(); + } + + [Test] + public async Task IsNullOrWhiteSpace_WithNullString_Passes() + { + string? nullString = null; + await Assert.That(nullString).IsNullOrWhiteSpace(); + } + + [Test] + public async Task IsNullOrWhiteSpace_WithEmptyString_Passes() + { + var emptyString = ""; + await Assert.That(emptyString).IsNullOrWhiteSpace(); + } + + [Test] + public async Task IsNullOrWhiteSpace_WithWhitespace_Passes() + { + var whitespace = " "; + await Assert.That(whitespace).IsNullOrWhiteSpace(); + } + + [Test] + public async Task IsNullOrWhiteSpace_WithNonEmptyString_Fails() + { + var value = "Hello"; + await Assert.That(async () => await Assert.That(value).IsNullOrWhiteSpace()) + .Throws(); + } + + [Test] + public async Task IsNotNullOrEmpty_WithNullString_Fails() + { + string? nullString = null; + await Assert.That(async () => await Assert.That(nullString).IsNotNullOrEmpty()) + .Throws(); + } + + [Test] + public async Task IsNotNullOrEmpty_WithEmptyString_Fails() + { + var emptyString = ""; + await Assert.That(async () => await Assert.That(emptyString).IsNotNullOrEmpty()) + .Throws(); + } + + [Test] + public async Task IsNotNullOrEmpty_WithNonEmptyString_Passes() + { + var value = "Hello"; + await Assert.That(value).IsNotNullOrEmpty(); + } +} diff --git a/TUnit.Assertions.Tests/TypeAssertionTests.cs b/TUnit.Assertions.Tests/TypeAssertionTests.cs index 1303b0188f..9bc01637b9 100644 --- a/TUnit.Assertions.Tests/TypeAssertionTests.cs +++ b/TUnit.Assertions.Tests/TypeAssertionTests.cs @@ -499,4 +499,180 @@ public async Task Test_Type_IsNotSerializable() await Assert.That(type).IsNotSerializable(); } #endif + + // Tests for issue #3425: Assertions before type conversion are not checked + [Test] + public async Task IsTypeOf_WithAnd_PreviousAssertion_ShouldFail() + { + // HasLength(4) should fail because "123" has length 3 + var exception = await Assert.That(async () => + { + var sut = "123"; + await Assert.That(sut) + .HasLength(4) + .And + .IsTypeOf() + .And + .HasLength(3); + }).ThrowsException().And.HasMessageContaining("HasLength(4)"); + } + + [Test] + public async Task IsTypeOf_WithAnd_PreviousAssertion_ShouldPass() + { + // Both assertions should pass + var sut = "123"; + await Assert.That(sut) + .HasLength(3) + .And + .IsTypeOf() + .And + .StartsWith("1"); + } + + [Test] + public async Task IsTypeOf_WithAnd_ObjectToString() + { + // IsNotNull should be checked before IsTypeOf + object? sut = "test"; + await Assert.That(sut) + .IsNotNull() + .And + .IsTypeOf() + .And + .HasLength(4); + } + + [Test] + public async Task IsTypeOf_WithAnd_NullCheck_ShouldFail() + { + // IsNotNull should fail + var exception = await Assert.That(async () => + { + object? sut = null; + await Assert.That(sut) + .IsNotNull() + .And + .IsTypeOf(); + }).ThrowsException().And.HasMessageContaining("IsNotNull"); + } + + [Test] + public async Task IsTypeOf_WithAnd_MultipleAssertionsBeforeTypeChange() + { + // All object assertions should be checked before type conversion + object? sut = "hello"; + await Assert.That(sut) + .IsNotNull() + .And + .IsNotEqualTo("world") + .And + .IsTypeOf() + .And + .HasLength(5) + .And + .StartsWith("h"); + } + + [Test] + public async Task IsTypeOf_WithAnd_SecondObjectAssertionFails() + { + // Second object assertion should fail before type conversion + var exception = await Assert.That(async () => + { + object? sut = "hello"; + await Assert.That(sut) + .IsNotNull() + .And + .IsEqualTo("world") + .And + .IsTypeOf(); + }).ThrowsException().And.HasMessageContaining("IsEqualTo"); + } + + [Test] + public async Task IsTypeOf_WithAnd_AssertionAfterTypeChangeFails() + { + // Object assertions pass, but string assertion fails + var exception = await Assert.That(async () => + { + object? sut = "hello"; + await Assert.That(sut) + .IsNotNull() + .And + .IsTypeOf() + .And + .HasLength(10); + }).ThrowsException().And.HasMessageContaining("HasLength"); + } + + [Test] + public async Task IsTypeOf_AssertMultiple_AllAssertionsChecked() + { + // In Assert.Multiple, all failing assertions should be captured + var exception = await Assert.That(async () => + { + using (Assert.Multiple()) + { + object? sut = "test"; + await Assert.That(sut) + .IsNotNull() + .And + .IsEqualTo("wrong") + .And + .IsTypeOf() + .And + .HasLength(10); + } + }).ThrowsException(); + + // Should have recorded the IsEqualTo failure at minimum + await Assert.That(exception.Message).Contains("IsEqualTo"); + } + + [Test] + public async Task IsTypeOf_ComplexChain_AllChecked() + { + // Complex chain with multiple type changes + object? sut = "12345"; + await Assert.That(sut) + .IsNotNull() + .And + .IsTypeOf() + .And + .HasLength(5) + .And + .StartsWith("1") + .And + .EndsWith("5"); + } + + [Test] + public async Task IsTypeOf_GenericObject_ToSpecificType() + { + // Generic object assertions before narrowing to specific type + object? sut = new List { 1, 2, 3 }; + await Assert.That(sut) + .IsNotNull() + .And + .IsTypeOf>() + .And + .HasCount(3); + } + + [Test] + public async Task IsTypeOf_GenericObject_TypeCheckFails() + { + // Previous assertion should be checked before type check fails + var exception = await Assert.That(async () => + { + object? sut = "string"; + await Assert.That(sut) + .IsNotNull() + .And + .IsEqualTo("wrong") + .And + .IsTypeOf(); + }).ThrowsException().And.HasMessageContaining("IsEqualTo"); + } } 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/Assertions/Strings/ParseAssertions.cs b/TUnit.Assertions/Assertions/Strings/ParseAssertions.cs index 92faa25398..626bcbd285 100644 --- a/TUnit.Assertions/Assertions/Strings/ParseAssertions.cs +++ b/TUnit.Assertions/Assertions/Strings/ParseAssertions.cs @@ -229,6 +229,16 @@ public WhenParsedIntoAssertion( IFormatProvider? formatProvider = null) : base(new AssertionContext(CreateParsedContext(stringContext.Evaluation, formatProvider), stringContext.ExpressionBuilder)) { + // Transfer pending links from string context to handle cross-type chaining + // e.g., Assert.That(str).HasLength(4).And.WhenParsedInto() + var (pendingAssertion, combinerType) = stringContext.ConsumePendingLink(); + if (pendingAssertion != null) + { + // Store the pending assertion execution as pre-work + // It will be executed before any assertions on the parsed value + Context.PendingPreWork = async () => await pendingAssertion.ExecuteCoreAsync(); + } + _formatProvider = formatProvider; } 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/Conditions/EqualsAssertion.cs b/TUnit.Assertions/Conditions/EqualsAssertion.cs index 19ac4469e2..4fd54bb749 100644 --- a/TUnit.Assertions/Conditions/EqualsAssertion.cs +++ b/TUnit.Assertions/Conditions/EqualsAssertion.cs @@ -12,7 +12,7 @@ namespace TUnit.Assertions.Conditions; /// public class EqualsAssertion : Assertion { - private readonly TValue _expected; + private readonly TValue? _expected; private readonly IEqualityComparer? _comparer; private object? _tolerance; private readonly HashSet _ignoredTypes = new(); @@ -244,11 +244,11 @@ private static bool CompareDecimal(decimal actual, decimal expected, object tole /// Gets the expected value for this equality assertion. /// Used by extension methods like Within() to create derived assertions. /// - public TValue Expected => _expected; + public TValue? Expected => _expected; public EqualsAssertion( AssertionContext context, - TValue expected, + TValue? expected, IEqualityComparer? comparer = null) : base(context) { @@ -346,7 +346,7 @@ protected override Task CheckAsync(EvaluationMetadata m // Standard equality comparison var comparer = _comparer ?? EqualityComparer.Default; - if (comparer.Equals(value!, _expected)) + if (comparer.Equals(value!, _expected!)) { return Task.FromResult(AssertionResult.Passed); } diff --git a/TUnit.Assertions/Conditions/TypeOfAssertion.cs b/TUnit.Assertions/Conditions/TypeOfAssertion.cs index 13f6cd4ee2..a14e4f3ec2 100644 --- a/TUnit.Assertions/Conditions/TypeOfAssertion.cs +++ b/TUnit.Assertions/Conditions/TypeOfAssertion.cs @@ -25,6 +25,16 @@ public TypeOfAssertion( $"Value is of type {value?.GetType().Name ?? "null"}, not {typeof(TTo).Name}"); })) { + // Transfer pending links from parent context to handle cross-type chaining + // e.g., Assert.That(obj).IsNotNull().And.IsTypeOf() + var (pendingAssertion, combinerType) = parentContext.ConsumePendingLink(); + if (pendingAssertion != null) + { + // Store the pending assertion execution as pre-work + // It will be executed before any assertions on the casted value + Context.PendingPreWork = async () => await pendingAssertion.ExecuteCoreAsync(); + } + _expectedType = typeof(TTo); } diff --git a/TUnit.Assertions/Core/Assertion.cs b/TUnit.Assertions/Core/Assertion.cs index 93b515421d..ecf9227156 100644 --- a/TUnit.Assertions/Core/Assertion.cs +++ b/TUnit.Assertions/Core/Assertion.cs @@ -118,6 +118,13 @@ public Assertion Because(string message) /// internal async Task ExecuteCoreAsync() { + // Execute any pending cross-type assertions first (e.g., string assertions before WhenParsedInto) + if (Context.PendingPreWork != null) + { + await Context.PendingPreWork(); + Context.PendingPreWork = null; // Execute only once + } + // If this is an And/OrAssertion (composite), delegate to AssertAsync which has custom logic if (this is Chaining.AndAssertion or Chaining.OrAssertion) { diff --git a/TUnit.Assertions/Core/AssertionContext.cs b/TUnit.Assertions/Core/AssertionContext.cs index 73c0e6b0a0..5b13957957 100644 --- a/TUnit.Assertions/Core/AssertionContext.cs +++ b/TUnit.Assertions/Core/AssertionContext.cs @@ -89,6 +89,12 @@ public AssertionContext MapException() where TException /// internal CombinerType? PendingLinkType { get; private set; } + /// + /// Pre-work to execute before evaluating assertions in this context. + /// Used for cross-type assertion chaining (e.g., string assertions before WhenParsedInto<int>). + /// + internal Func? PendingPreWork { get; set; } + /// /// Sets the pending link state for the next assertion to consume. /// Called by AndContinuation/OrContinuation constructors. diff --git a/TUnit.Assertions/Extensions/AssertionExtensions.cs b/TUnit.Assertions/Extensions/AssertionExtensions.cs index 69b03d78f6..4146841441 100644 --- a/TUnit.Assertions/Extensions/AssertionExtensions.cs +++ b/TUnit.Assertions/Extensions/AssertionExtensions.cs @@ -31,7 +31,7 @@ public static class AssertionExtensions [OverloadResolutionPriority(0)] public static EqualsAssertion IsEqualTo( this IAssertionSource source, - TValue expected, + TValue? expected, [CallerArgumentExpression(nameof(expected))] string? expression = null) { source.Context.ExpressionBuilder.Append($".IsEqualTo({expression})"); @@ -44,7 +44,7 @@ public static EqualsAssertion IsEqualTo( /// public static EqualsAssertion EqualTo( this IAssertionSource source, - TValue expected, + TValue? expected, [CallerArgumentExpression(nameof(expected))] string? expression = null) { source.Context.ExpressionBuilder.Append($".EqualTo({expression})"); @@ -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); + 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, TItem>(source.Context); } /// @@ -1518,6 +1543,203 @@ public static GreaterThanOrEqualAssertion IsAfterOrEqualTo( return new GreaterThanOrEqualAssertion(source.Context, expected); } + /// + /// Asserts that the DateTime is after the expected DateTime. + /// Alias for IsGreaterThan for better readability with dates. + /// + public static GreaterThanAssertion IsAfter( + this IAssertionSource source, + DateTime expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + source.Context.ExpressionBuilder.Append($".IsAfter({expression})"); + return new GreaterThanAssertion(source.Context, expected); + } + + /// + /// Asserts that the DateTime is before the expected DateTime. + /// Alias for IsLessThan for better readability with dates. + /// + public static LessThanAssertion IsBefore( + this IAssertionSource source, + DateTime expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + source.Context.ExpressionBuilder.Append($".IsBefore({expression})"); + return new LessThanAssertion(source.Context, expected); + } + + /// + /// Asserts that the DateTime is before or equal to the expected DateTime. + /// Alias for IsLessThanOrEqualTo for better readability with dates. + /// + public static LessThanOrEqualAssertion IsBeforeOrEqualTo( + this IAssertionSource source, + DateTime expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + source.Context.ExpressionBuilder.Append($".IsBeforeOrEqualTo({expression})"); + return new LessThanOrEqualAssertion(source.Context, expected); + } + + /// + /// Asserts that the DateTimeOffset is after the expected DateTimeOffset. + /// Alias for IsGreaterThan for better readability with dates. + /// + public static GreaterThanAssertion IsAfter( + this IAssertionSource source, + DateTimeOffset expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + source.Context.ExpressionBuilder.Append($".IsAfter({expression})"); + return new GreaterThanAssertion(source.Context, expected); + } + + /// + /// Asserts that the DateTimeOffset is before the expected DateTimeOffset. + /// Alias for IsLessThan for better readability with dates. + /// + public static LessThanAssertion IsBefore( + this IAssertionSource source, + DateTimeOffset expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + source.Context.ExpressionBuilder.Append($".IsBefore({expression})"); + return new LessThanAssertion(source.Context, expected); + } + + /// + /// Asserts that the DateTimeOffset is after or equal to the expected DateTimeOffset. + /// Alias for IsGreaterThanOrEqualTo for better readability with dates. + /// + public static GreaterThanOrEqualAssertion IsAfterOrEqualTo( + this IAssertionSource source, + DateTimeOffset expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + source.Context.ExpressionBuilder.Append($".IsAfterOrEqualTo({expression})"); + return new GreaterThanOrEqualAssertion(source.Context, expected); + } + + /// + /// Asserts that the DateTimeOffset is before or equal to the expected DateTimeOffset. + /// Alias for IsLessThanOrEqualTo for better readability with dates. + /// + public static LessThanOrEqualAssertion IsBeforeOrEqualTo( + this IAssertionSource source, + DateTimeOffset expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + source.Context.ExpressionBuilder.Append($".IsBeforeOrEqualTo({expression})"); + return new LessThanOrEqualAssertion(source.Context, expected); + } + +#if NET6_0_OR_GREATER + /// + /// Asserts that the DateOnly is after the expected DateOnly. + /// Alias for IsGreaterThan for better readability with dates. + /// + public static GreaterThanAssertion IsAfter( + this IAssertionSource source, + DateOnly expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + source.Context.ExpressionBuilder.Append($".IsAfter({expression})"); + return new GreaterThanAssertion(source.Context, expected); + } + + /// + /// Asserts that the DateOnly is before the expected DateOnly. + /// Alias for IsLessThan for better readability with dates. + /// + public static LessThanAssertion IsBefore( + this IAssertionSource source, + DateOnly expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + source.Context.ExpressionBuilder.Append($".IsBefore({expression})"); + return new LessThanAssertion(source.Context, expected); + } + + /// + /// Asserts that the DateOnly is after or equal to the expected DateOnly. + /// Alias for IsGreaterThanOrEqualTo for better readability with dates. + /// + public static GreaterThanOrEqualAssertion IsAfterOrEqualTo( + this IAssertionSource source, + DateOnly expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + source.Context.ExpressionBuilder.Append($".IsAfterOrEqualTo({expression})"); + return new GreaterThanOrEqualAssertion(source.Context, expected); + } + + /// + /// Asserts that the DateOnly is before or equal to the expected DateOnly. + /// Alias for IsLessThanOrEqualTo for better readability with dates. + /// + public static LessThanOrEqualAssertion IsBeforeOrEqualTo( + this IAssertionSource source, + DateOnly expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + source.Context.ExpressionBuilder.Append($".IsBeforeOrEqualTo({expression})"); + return new LessThanOrEqualAssertion(source.Context, expected); + } + + /// + /// Asserts that the TimeOnly is after the expected TimeOnly. + /// Alias for IsGreaterThan for better readability with times. + /// + public static GreaterThanAssertion IsAfter( + this IAssertionSource source, + TimeOnly expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + source.Context.ExpressionBuilder.Append($".IsAfter({expression})"); + return new GreaterThanAssertion(source.Context, expected); + } + + /// + /// Asserts that the TimeOnly is before the expected TimeOnly. + /// Alias for IsLessThan for better readability with times. + /// + public static LessThanAssertion IsBefore( + this IAssertionSource source, + TimeOnly expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + source.Context.ExpressionBuilder.Append($".IsBefore({expression})"); + return new LessThanAssertion(source.Context, expected); + } + + /// + /// Asserts that the TimeOnly is after or equal to the expected TimeOnly. + /// Alias for IsGreaterThanOrEqualTo for better readability with times. + /// + public static GreaterThanOrEqualAssertion IsAfterOrEqualTo( + this IAssertionSource source, + TimeOnly expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + source.Context.ExpressionBuilder.Append($".IsAfterOrEqualTo({expression})"); + return new GreaterThanOrEqualAssertion(source.Context, expected); + } + + /// + /// Asserts that the TimeOnly is before or equal to the expected TimeOnly. + /// Alias for IsLessThanOrEqualTo for better readability with times. + /// + public static LessThanOrEqualAssertion IsBeforeOrEqualTo( + this IAssertionSource source, + TimeOnly expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + source.Context.ExpressionBuilder.Append($".IsBeforeOrEqualTo({expression})"); + return new LessThanOrEqualAssertion(source.Context, expected); + } +#endif + // IsDefault and IsNotDefault are now generated by AssertionExtensionGenerator // ============ TIMING ASSERTIONS ============ diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs index b5b868b725..e603a65bbd 100644 --- a/TUnit.Engine/Framework/TUnitServiceProvider.cs +++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs @@ -193,7 +193,8 @@ public TUnitServiceProvider(IExtension extension, TestExecutor, testInitializer, objectTracker, - Logger)); + Logger, + EventReceiverOrchestrator)); // Create the HookOrchestratingTestExecutorAdapter // Note: We'll need to update this to handle dynamic dependencies properly diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index 4a79021c62..0d78977f70 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -22,6 +22,7 @@ internal sealed class TestCoordinator : ITestCoordinator private readonly TestInitializer _testInitializer; private readonly ObjectTracker _objectTracker; private readonly TUnitFrameworkLogger _logger; + private readonly EventReceiverOrchestrator _eventReceiverOrchestrator; public TestCoordinator( TestExecutionGuard executionGuard, @@ -31,7 +32,8 @@ public TestCoordinator( TestExecutor testExecutor, TestInitializer testInitializer, ObjectTracker objectTracker, - TUnitFrameworkLogger logger) + TUnitFrameworkLogger logger, + EventReceiverOrchestrator eventReceiverOrchestrator) { _executionGuard = executionGuard; _stateManager = stateManager; @@ -41,6 +43,7 @@ public TestCoordinator( _testInitializer = testInitializer; _objectTracker = objectTracker; _logger = logger; + _eventReceiverOrchestrator = eventReceiverOrchestrator; } #if NET6_0_OR_GREATER @@ -92,6 +95,13 @@ await RetryHelper.ExecuteWithRetry(test.Context, async () => !string.IsNullOrEmpty(test.Context.SkipReason)) { await _stateManager.MarkSkippedAsync(test, test.Context.SkipReason ?? "Test was skipped"); + + // Invoke skipped event receivers + await _eventReceiverOrchestrator.InvokeTestSkippedEventReceiversAsync(test.Context, cancellationToken); + + // Invoke test end event receivers for skipped tests + await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(test.Context, cancellationToken); + return; } @@ -137,6 +147,12 @@ await RetryHelper.ExecuteWithRetry(test.Context, async () => catch (SkipTestException ex) { await _stateManager.MarkSkippedAsync(test, ex.Message); + + // Invoke skipped event receivers + await _eventReceiverOrchestrator.InvokeTestSkippedEventReceiversAsync(test.Context, cancellationToken); + + // Invoke test end event receivers for skipped tests + await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(test.Context, cancellationToken); } catch (Exception ex) { @@ -161,6 +177,46 @@ await RetryHelper.ExecuteWithRetry(test.Context, async () => cleanupExceptions.AddRange(hookExceptions); } + // Invoke Last event receivers for class and assembly + try + { + await _eventReceiverOrchestrator.InvokeLastTestInClassEventReceiversAsync( + test.Context, + test.Context.ClassContext, + CancellationToken.None); + } + catch (Exception ex) + { + await _logger.LogErrorAsync($"Error in last test in class event receiver for {test.TestId}: {ex}"); + cleanupExceptions.Add(ex); + } + + try + { + await _eventReceiverOrchestrator.InvokeLastTestInAssemblyEventReceiversAsync( + test.Context, + test.Context.ClassContext.AssemblyContext, + CancellationToken.None); + } + catch (Exception ex) + { + await _logger.LogErrorAsync($"Error in last test in assembly event receiver for {test.TestId}: {ex}"); + cleanupExceptions.Add(ex); + } + + try + { + await _eventReceiverOrchestrator.InvokeLastTestInSessionEventReceiversAsync( + test.Context, + test.Context.ClassContext.AssemblyContext.TestSessionContext, + CancellationToken.None); + } + catch (Exception ex) + { + await _logger.LogErrorAsync($"Error in last test in session event receiver for {test.TestId}: {ex}"); + cleanupExceptions.Add(ex); + } + // If any cleanup exceptions occurred, mark the test as failed if (cleanupExceptions.Count > 0) { 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 bbb838e56f..4cf0af00b7 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 @@ -676,7 +676,7 @@ namespace .Conditions } public class EqualsAssertion : . { - public EqualsAssertion(. context, TValue expected, .? comparer = null) { } + public EqualsAssertion(. context, TValue? expected, .? comparer = null) { } public TValue Expected { get; } [.("Trimming", "IL2075", Justification="Tolerance comparison requires dynamic invocation of known comparer delegates")] protected override .<.> CheckAsync(. metadata) { } @@ -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 @@ -1669,7 +1670,7 @@ namespace .Extensions where TEnum : struct, { } public static ..DoesNotHaveSameValueAsAssertion DoesNotHaveSameValueAs(this . source, otherEnumValue, [.("otherEnumValue")] string? expression = null) where TEnum : struct, { } - public static . EqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } + public static . EqualTo(this . source, TValue? expected, [.("expected")] string? expression = null) { } public static ..CountWrapper HasCount(this . source) where TValue : .IEnumerable { } public static . HasCount(this . source, int expectedCount, [.("expectedCount")] string? expression = null) @@ -1692,11 +1693,28 @@ 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) { } + public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsAfterOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsAfterOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsAfterOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } public static .<> IsAfterOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } public static . IsAssignableTo(this . source) { } public static . IsAssignableTo(this . source) { } + public static .<> IsBefore(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBefore(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBefore(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBefore(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBeforeOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBeforeOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBeforeOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBeforeOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } public static ..IsDefinedAssertion IsDefined(this . source) where TEnum : struct, { } public static . IsEmpty(this . source) @@ -1723,7 +1741,7 @@ namespace .Extensions public static . IsEqualTo(this . source, string? expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, string? expected, comparison, [.("expected")] string? expression = null) { } [.(0)] - public static . IsEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } + public static . IsEqualTo(this . source, TValue? expected, [.("expected")] string? expression = null) { } [.(-1)] public static . IsEqualTo(this . source, TExpected expected, [.("expected")] string? expression = null) where TActual : struct, { } 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 c0bb464850..bfc649440c 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 @@ -676,7 +676,7 @@ namespace .Conditions } public class EqualsAssertion : . { - public EqualsAssertion(. context, TValue expected, .? comparer = null) { } + public EqualsAssertion(. context, TValue? expected, .? comparer = null) { } public TValue Expected { get; } [.("Trimming", "IL2075", Justification="Tolerance comparison requires dynamic invocation of known comparer delegates")] protected override .<.> CheckAsync(. metadata) { } @@ -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 @@ -1669,7 +1670,7 @@ namespace .Extensions where TEnum : struct, { } public static ..DoesNotHaveSameValueAsAssertion DoesNotHaveSameValueAs(this . source, otherEnumValue, [.("otherEnumValue")] string? expression = null) where TEnum : struct, { } - public static . EqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } + public static . EqualTo(this . source, TValue? expected, [.("expected")] string? expression = null) { } public static ..CountWrapper HasCount(this . source) where TValue : .IEnumerable { } public static . HasCount(this . source, int expectedCount, [.("expectedCount")] string? expression = null) @@ -1692,11 +1693,28 @@ 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) { } + public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsAfterOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsAfterOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsAfterOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } public static .<> IsAfterOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } public static . IsAssignableTo(this . source) { } public static . IsAssignableTo(this . source) { } + public static .<> IsBefore(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBefore(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBefore(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBefore(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBeforeOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBeforeOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBeforeOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBeforeOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } public static ..IsDefinedAssertion IsDefined(this . source) where TEnum : struct, { } public static . IsEmpty(this . source) @@ -1712,7 +1730,7 @@ namespace .Extensions public static . IsEqualTo(this . source, long expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, string? expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, string? expected, comparison, [.("expected")] string? expression = null) { } - public static . IsEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } + public static . IsEqualTo(this . source, TValue? expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, TExpected expected, [.("expected")] string? expression = null) where TActual : struct, { } public static . IsEqualTo(this . source, TExpected 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 62e5f4c4fe..8837dd000e 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 @@ -676,7 +676,7 @@ namespace .Conditions } public class EqualsAssertion : . { - public EqualsAssertion(. context, TValue expected, .? comparer = null) { } + public EqualsAssertion(. context, TValue? expected, .? comparer = null) { } public TValue Expected { get; } [.("Trimming", "IL2075", Justification="Tolerance comparison requires dynamic invocation of known comparer delegates")] protected override .<.> CheckAsync(. metadata) { } @@ -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 @@ -1669,7 +1670,7 @@ namespace .Extensions where TEnum : struct, { } public static ..DoesNotHaveSameValueAsAssertion DoesNotHaveSameValueAs(this . source, otherEnumValue, [.("otherEnumValue")] string? expression = null) where TEnum : struct, { } - public static . EqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } + public static . EqualTo(this . source, TValue? expected, [.("expected")] string? expression = null) { } public static ..CountWrapper HasCount(this . source) where TValue : .IEnumerable { } public static . HasCount(this . source, int expectedCount, [.("expectedCount")] string? expression = null) @@ -1692,11 +1693,28 @@ 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) { } + public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsAfterOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsAfterOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsAfterOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } public static .<> IsAfterOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } public static . IsAssignableTo(this . source) { } public static . IsAssignableTo(this . source) { } + public static .<> IsBefore(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBefore(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBefore(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBefore(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBeforeOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBeforeOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBeforeOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBeforeOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } public static ..IsDefinedAssertion IsDefined(this . source) where TEnum : struct, { } public static . IsEmpty(this . source) @@ -1723,7 +1741,7 @@ namespace .Extensions public static . IsEqualTo(this . source, string? expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, string? expected, comparison, [.("expected")] string? expression = null) { } [.(0)] - public static . IsEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } + public static . IsEqualTo(this . source, TValue? expected, [.("expected")] string? expression = null) { } [.(-1)] public static . IsEqualTo(this . source, TExpected expected, [.("expected")] string? expression = null) where TActual : struct, { } 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 bed7efbb80..2a24fafdb3 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 @@ -644,7 +644,7 @@ namespace .Conditions } public class EqualsAssertion : . { - public EqualsAssertion(. context, TValue expected, .? comparer = null) { } + public EqualsAssertion(. context, TValue? expected, .? comparer = null) { } public TValue Expected { get; } protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } @@ -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 @@ -1574,7 +1575,7 @@ namespace .Extensions where TEnum : struct, { } public static ..DoesNotHaveSameValueAsAssertion DoesNotHaveSameValueAs(this . source, otherEnumValue, [.("otherEnumValue")] string? expression = null) where TEnum : struct, { } - public static . EqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } + public static . EqualTo(this . source, TValue? expected, [.("expected")] string? expression = null) { } public static ..CountWrapper HasCount(this . source) where TValue : .IEnumerable { } public static . HasCount(this . source, int expectedCount, [.("expectedCount")] string? expression = null) @@ -1597,11 +1598,20 @@ 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) { } public static .<> IsAfterOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } public static . IsAssignableTo(this . source) { } public static . IsAssignableTo(this . source) { } + public static .<> IsBefore(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBefore(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBeforeOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsBeforeOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } public static ..IsDefinedAssertion IsDefined(this . source) where TEnum : struct, { } public static . IsEmpty(this . source) @@ -1615,7 +1625,7 @@ namespace .Extensions public static . IsEqualTo(this . source, long expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, string? expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, string? expected, comparison, [.("expected")] string? expression = null) { } - public static . IsEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } + public static . IsEqualTo(this . source, TValue? expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, TExpected expected, [.("expected")] string? expression = null) where TActual : struct, { } public static . IsEqualTo(this . source, TExpected expected, [.("expected")] string? expression = null) diff --git a/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj b/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj index ff16a97f75..a4ec73fb61 100644 --- a/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj +++ b/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj @@ -10,8 +10,8 @@ - - + + diff --git a/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj b/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj index 4fb4d4118f..71e06d6661 100644 --- a/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj +++ b/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj @@ -9,7 +9,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj index 8fcc19d2b3..0ac3ca4eec 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj @@ -11,7 +11,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj index bee487cd92..00131c94f6 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj @@ -10,7 +10,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj index 3a64f61b82..4e5f722fdb 100644 --- a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj +++ b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj @@ -10,8 +10,8 @@ - - + + diff --git a/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj b/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj index a464be9ee1..c387f0ed50 100644 --- a/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj +++ b/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj @@ -8,7 +8,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.VB/TestProject.vbproj b/TUnit.Templates/content/TUnit.VB/TestProject.vbproj index 72af11b442..724abd95b6 100644 --- a/TUnit.Templates/content/TUnit.VB/TestProject.vbproj +++ b/TUnit.Templates/content/TUnit.VB/TestProject.vbproj @@ -8,6 +8,6 @@ - + diff --git a/TUnit.Templates/content/TUnit/TestProject.csproj b/TUnit.Templates/content/TUnit/TestProject.csproj index 90566cf325..7ffa0fb973 100644 --- a/TUnit.Templates/content/TUnit/TestProject.csproj +++ b/TUnit.Templates/content/TUnit/TestProject.csproj @@ -8,7 +8,7 @@ - + \ No newline at end of file diff --git a/TUnit.TestProject/LastTestEventReceiverTests.cs b/TUnit.TestProject/LastTestEventReceiverTests.cs new file mode 100644 index 0000000000..b239fb8a5d --- /dev/null +++ b/TUnit.TestProject/LastTestEventReceiverTests.cs @@ -0,0 +1,165 @@ +using TUnit.Core.Interfaces; + +namespace TUnit.TestProject; + +public class LastTestEventReceiverTests +{ + public static readonly List Events = []; + + [Before(Test)] + public void ClearEvents() + { + Events.Clear(); + } + + [Test] + [LastTestEventReceiver] + public async Task Test1() + { + await Task.Delay(10); + } + + [Test] + [LastTestEventReceiver] + public async Task Test2() + { + await Task.Delay(10); + } + + [Test] + [LastTestEventReceiver] + public async Task Test3() + { + await Task.Delay(10); + } + + [After(Test)] + public async Task VerifyLastTestEventFired(TestContext context) + { + // Give some time for async event receivers to complete + await Task.Delay(100); + + var displayName = context.GetDisplayName(); + + // After the last test (Test3), we should have the last test event recorded + if (displayName.Contains("Test3")) + { + await Assert.That(Events).Contains("LastTestInClass"); + } + } +} + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)] +public class LastTestEventReceiverAttribute : Attribute, + ITestStartEventReceiver, + ITestEndEventReceiver, + ILastTestInClassEventReceiver +{ + public int Order => 0; + + public ValueTask OnTestStart(TestContext context) + { + LastTestEventReceiverTests.Events.Add($"TestStart: {context.GetDisplayName()}"); + return default; + } + + public ValueTask OnTestEnd(TestContext context) + { + LastTestEventReceiverTests.Events.Add($"TestEnd: {context.GetDisplayName()}"); + return default; + } + + public ValueTask OnLastTestInClass(ClassHookContext context, TestContext testContext) + { + LastTestEventReceiverTests.Events.Add("LastTestInClass"); + return default; + } +} + +// Separate test class to test assembly-level last test event +public class LastTestInAssemblyEventReceiverTests +{ + public static readonly List Events = []; + + [Before(Test)] + public void ClearEvents() + { + Events.Clear(); + } + + [Test] + [LastTestInAssemblyEventReceiver] + public async Task AssemblyTest() + { + await Task.Delay(10); + } +} + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)] +public class LastTestInAssemblyEventReceiverAttribute : Attribute, + ILastTestInAssemblyEventReceiver +{ + public int Order => 0; + + public ValueTask OnLastTestInAssembly(AssemblyHookContext context, TestContext testContext) + { + LastTestInAssemblyEventReceiverTests.Events.Add("LastTestInAssembly"); + return default; + } +} + +// Test for skipped event receivers +public class SkippedEventReceiverTests +{ + public static readonly List Events = []; + public static string? CapturedSkipReason = null; + + [Before(Test)] + public void ClearEvents() + { + Events.Clear(); + CapturedSkipReason = null; + } + + [Test, Skip("Testing skip event with custom reason")] + [SkipEventReceiverAttribute] + public async Task SkippedTestWithCustomReason() + { + await Task.Delay(10); + } + + [After(Test)] + public async Task VerifySkipEventFired(TestContext context) + { + // Give some time for async event receivers to complete + await Task.Delay(100); + + if (context.GetDisplayName().Contains("SkippedTestWithCustomReason")) + { + await Assert.That(Events).Contains("TestSkipped"); + await Assert.That(Events).Contains("TestEnd"); + await Assert.That(CapturedSkipReason).IsEqualTo("Testing skip event with custom reason"); + } + } +} + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)] +public class SkipEventReceiverAttribute : Attribute, + ITestSkippedEventReceiver, + ITestEndEventReceiver +{ + public int Order => 0; + + public ValueTask OnTestSkipped(TestContext context) + { + SkippedEventReceiverTests.Events.Add("TestSkipped"); + SkippedEventReceiverTests.CapturedSkipReason = context.SkipReason; + return default; + } + + public ValueTask OnTestEnd(TestContext context) + { + SkippedEventReceiverTests.Events.Add("TestEnd"); + return default; + } +} 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]