diff --git a/.gitattributes b/.gitattributes index 92aeca4250..91e319e9a8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,56 @@ ############################################################################### * text=auto -# Verify -*.verified.txt text eol=lf working-tree-encoding=UTF-8 -*.received.txt text eol=lf working-tree-encoding=UTF-8 \ No newline at end of file +############################################################################### +# Source code - normalize to LF in repo, native on checkout +############################################################################### +*.cs text diff=csharp +*.csx text diff=csharp +*.csproj text +*.sln text +*.slnx text +*.props text +*.targets text +*.json text +*.xml text +*.yml text +*.yaml text +*.md text +*.txt text +*.config text +*.editorconfig text +*.razor text +*.cshtml text + +############################################################################### +# Shell scripts - always LF +############################################################################### +*.sh text eol=lf +*.bash text eol=lf + +############################################################################### +# Windows scripts - always CRLF +############################################################################### +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +############################################################################### +# Verify snapshots - LF for consistent cross-platform diffs +############################################################################### +*.verified.txt text eol=lf +*.received.txt text eol=lf + +############################################################################### +# Binary files - no normalization +############################################################################### +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.dll binary +*.exe binary +*.nupkg binary +*.snk binary +*.pfx binary diff --git a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithInnerExceptionsTests.cs b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithInnerExceptionsTests.cs new file mode 100644 index 0000000000..4b06aea0e7 --- /dev/null +++ b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithInnerExceptionsTests.cs @@ -0,0 +1,80 @@ +namespace TUnit.Assertions.Tests.Assertions.Delegates; + +public partial class Throws +{ + public class WithInnerExceptionsTests + { + [Test] + public async Task WithInnerExceptions_Count_Succeeds() + { + var aggregate = new AggregateException( + new InvalidOperationException("one"), + new ArgumentException("two"), + new FormatException("three")); + Action action = () => throw aggregate; + + await Assert.That(action) + .Throws() + .WithInnerExceptions(exceptions => exceptions.Count().IsEqualTo(3)); + } + + [Test] + public async Task WithInnerExceptions_Count_Fails() + { + var aggregate = new AggregateException( + new InvalidOperationException("one"), + new ArgumentException("two")); + Action action = () => throw aggregate; + + var sut = async () => await Assert.That(action) + .Throws() + .WithInnerExceptions(exceptions => exceptions.Count().IsEqualTo(5)); + + await Assert.That(sut).ThrowsException(); + } + + [Test] + public async Task WithInnerExceptions_AllSatisfy_Succeeds() + { + var aggregate = new AggregateException( + new ArgumentException("one", "param1"), + new ArgumentException("two", "param2"), + new ArgumentException("three", "param3")); + Action action = () => throw aggregate; + + await Assert.That(action) + .Throws() + .WithInnerExceptions(exceptions => exceptions + .All().Satisfy(e => e.IsTypeOf())); + } + + [Test] + public async Task WithInnerExceptions_AllSatisfy_Fails_When_Mixed_Types() + { + var aggregate = new AggregateException( + new ArgumentException("one"), + new FormatException("two")); + Action action = () => throw aggregate; + + var sut = async () => await Assert.That(action) + .Throws() + .WithInnerExceptions(exceptions => exceptions + .All().Satisfy(e => e.IsTypeOf())); + + await Assert.That(sut).ThrowsException(); + } + + [Test] + public async Task WithInnerExceptions_ThrowsExactly_Count_Succeeds() + { + var aggregate = new AggregateException( + new InvalidOperationException("one"), + new ArgumentException("two")); + Action action = () => throw aggregate; + + await Assert.That(action) + .ThrowsExactly() + .WithInnerExceptions(exceptions => exceptions.Count().IsEqualTo(2)); + } + } +} diff --git a/TUnit.Assertions/Conditions/WithInnerExceptionsAssertion.cs b/TUnit.Assertions/Conditions/WithInnerExceptionsAssertion.cs new file mode 100644 index 0000000000..302451d121 --- /dev/null +++ b/TUnit.Assertions/Conditions/WithInnerExceptionsAssertion.cs @@ -0,0 +1,57 @@ +using TUnit.Assertions.Core; +using TUnit.Assertions.Sources; + +namespace TUnit.Assertions.Conditions; + +/// +/// Asserts on the InnerExceptions collection of an AggregateException using an inline delegate. +/// The delegate receives a CollectionAssertion<Exception> for the InnerExceptions, enabling +/// full collection assertion chaining (Count, All().Satisfy, Contains, etc.). +/// +public class WithInnerExceptionsAssertion : Assertion +{ + private readonly Func, Assertion>?> _innerExceptionsAssertion; + + internal WithInnerExceptionsAssertion( + AssertionContext context, + Func, Assertion>?> innerExceptionsAssertion) + : base(context) + { + _innerExceptionsAssertion = innerExceptionsAssertion; + } + + protected override async Task CheckAsync(EvaluationMetadata metadata) + { + var evaluationException = metadata.Exception; + if (evaluationException != null) + { + return AssertionResult.Failed($"threw {evaluationException.GetType().FullName}"); + } + + var aggregateException = metadata.Value; + + if (aggregateException == null) + { + return AssertionResult.Failed("exception was null"); + } + + var collectionSource = new CollectionAssertion(aggregateException.InnerExceptions, "InnerExceptions"); + var resultingAssertion = _innerExceptionsAssertion(collectionSource); + + if (resultingAssertion != null) + { + try + { + await resultingAssertion.AssertAsync(); + } + catch (Exception ex) + { + return AssertionResult.Failed($"inner exceptions did not satisfy assertion: {ex.Message}"); + } + } + + return AssertionResult.Passed; + } + + protected override string GetExpectation() => "to have inner exceptions satisfying assertion"; +} diff --git a/TUnit.Assertions/Extensions/AssertionExtensions.cs b/TUnit.Assertions/Extensions/AssertionExtensions.cs index 97b713d664..efc3667aba 100644 --- a/TUnit.Assertions/Extensions/AssertionExtensions.cs +++ b/TUnit.Assertions/Extensions/AssertionExtensions.cs @@ -1163,6 +1163,34 @@ public static ThrowsExactlyAssertion ThrowsExactly(this return new ThrowsExactlyAssertion(mappedContext); } + /// + /// Asserts on the InnerExceptions collection of an AggregateException using an inline assertion delegate. + /// Only available when the thrown exception type is AggregateException. + /// Example: await Assert.That(action).Throws<AggregateException>().WithInnerExceptions(e => e.Count().IsEqualTo(3)); + /// + public static WithInnerExceptionsAssertion WithInnerExceptions( + this ThrowsAssertion source, + Func, Assertion>?> innerExceptionsAssertion, + [CallerArgumentExpression(nameof(innerExceptionsAssertion))] string? expression = null) + { + source.InternalContext.ExpressionBuilder.Append($".WithInnerExceptions({expression})"); + return new WithInnerExceptionsAssertion(source.InternalContext, innerExceptionsAssertion); + } + + /// + /// Asserts on the InnerExceptions collection of an AggregateException using an inline assertion delegate. + /// Only available when the thrown exception type is AggregateException. + /// Example: await Assert.That(action).ThrowsExactly<AggregateException>().WithInnerExceptions(e => e.Count().IsEqualTo(3)); + /// + public static WithInnerExceptionsAssertion WithInnerExceptions( + this ThrowsExactlyAssertion source, + Func, Assertion>?> innerExceptionsAssertion, + [CallerArgumentExpression(nameof(innerExceptionsAssertion))] string? expression = null) + { + source.InternalContext.ExpressionBuilder.Append($".WithInnerExceptions({expression})"); + return new WithInnerExceptionsAssertion(source.InternalContext, innerExceptionsAssertion); + } + /// /// Asserts that an exception's Message property exactly equals the expected string. /// Works with both direct exception assertions and chained exception assertions (via .And). 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 b897d0c5d6..d275bc4517 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 @@ -2272,6 +2272,11 @@ namespace .Conditions [.<>("TrackResurrection", CustomName="DoesNotTrackResurrection", ExpectationMessage="track resurrection", NegateLogic=true)] [.<>("TrackResurrection", ExpectationMessage="track resurrection")] public static class WeakReferenceAssertionExtensions { } + public class WithInnerExceptionsAssertion : .<> + { + protected override .<.> CheckAsync(.<> metadata) { } + protected override string GetExpectation() { } + } } namespace . { @@ -2725,6 +2730,8 @@ namespace .Extensions public static . WithInnerException(this . source) where TException : where TInnerException : { } + public static . WithInnerExceptions(this .<> source, <.<>, .<.<>>?> innerExceptionsAssertion, [.("innerExceptionsAssertion")] string? expression = null) { } + public static . WithInnerExceptions(this .<> source, <.<>, .<.<>>?> innerExceptionsAssertion, [.("innerExceptionsAssertion")] string? expression = null) { } public static . WithMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null) where TException : { } public static . WithMessage(this . source, string expectedMessage, comparison, [.("expectedMessage")] 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 68131a895b..8e96e7b507 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 @@ -2255,6 +2255,11 @@ namespace .Conditions [.<>("TrackResurrection", CustomName="DoesNotTrackResurrection", ExpectationMessage="track resurrection", NegateLogic=true)] [.<>("TrackResurrection", ExpectationMessage="track resurrection")] public static class WeakReferenceAssertionExtensions { } + public class WithInnerExceptionsAssertion : .<> + { + protected override .<.> CheckAsync(.<> metadata) { } + protected override string GetExpectation() { } + } } namespace . { @@ -2697,6 +2702,8 @@ namespace .Extensions public static . WithInnerException(this . source) where TException : where TInnerException : { } + public static . WithInnerExceptions(this .<> source, <.<>, .<.<>>?> innerExceptionsAssertion, [.("innerExceptionsAssertion")] string? expression = null) { } + public static . WithInnerExceptions(this .<> source, <.<>, .<.<>>?> innerExceptionsAssertion, [.("innerExceptionsAssertion")] string? expression = null) { } public static . WithMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null) where TException : { } public static . WithMessage(this . source, string expectedMessage, comparison, [.("expectedMessage")] 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 e896d0349d..130da6da01 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 @@ -2272,6 +2272,11 @@ namespace .Conditions [.<>("TrackResurrection", CustomName="DoesNotTrackResurrection", ExpectationMessage="track resurrection", NegateLogic=true)] [.<>("TrackResurrection", ExpectationMessage="track resurrection")] public static class WeakReferenceAssertionExtensions { } + public class WithInnerExceptionsAssertion : .<> + { + protected override .<.> CheckAsync(.<> metadata) { } + protected override string GetExpectation() { } + } } namespace . { @@ -2725,6 +2730,8 @@ namespace .Extensions public static . WithInnerException(this . source) where TException : where TInnerException : { } + public static . WithInnerExceptions(this .<> source, <.<>, .<.<>>?> innerExceptionsAssertion, [.("innerExceptionsAssertion")] string? expression = null) { } + public static . WithInnerExceptions(this .<> source, <.<>, .<.<>>?> innerExceptionsAssertion, [.("innerExceptionsAssertion")] string? expression = null) { } public static . WithMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null) where TException : { } public static . WithMessage(this . source, string expectedMessage, comparison, [.("expectedMessage")] 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 a88feff098..d6535daa29 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 @@ -2033,6 +2033,11 @@ namespace .Conditions [.<>("TrackResurrection", CustomName="DoesNotTrackResurrection", ExpectationMessage="track resurrection", NegateLogic=true)] [.<>("TrackResurrection", ExpectationMessage="track resurrection")] public static class WeakReferenceAssertionExtensions { } + public class WithInnerExceptionsAssertion : .<> + { + protected override .<.> CheckAsync(.<> metadata) { } + protected override string GetExpectation() { } + } } namespace . { @@ -2437,6 +2442,8 @@ namespace .Extensions public static . WithInnerException(this . source) where TException : where TInnerException : { } + public static . WithInnerExceptions(this .<> source, <.<>, .<.<>>?> innerExceptionsAssertion, [.("innerExceptionsAssertion")] string? expression = null) { } + public static . WithInnerExceptions(this .<> source, <.<>, .<.<>>?> innerExceptionsAssertion, [.("innerExceptionsAssertion")] string? expression = null) { } public static . WithMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null) where TException : { } public static . WithMessage(this . source, string expectedMessage, comparison, [.("expectedMessage")] string? expression = null)