Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Source/aweXpect.Core/Core/ExpectationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,15 @@ internal void AddReason(string reason)
_node.SetReason(becauseReason);
}

/// <summary>
/// Adds a <paramref name="reason" /> to the current expectation constraint.
/// </summary>
internal void AddReason(Task<string> reason)
{
AsyncBecauseReason becauseReason = new(reason);
_node.SetReason(becauseReason);
}

/// <summary>
/// Supports chaining for subsequent expectation constraints with the <paramref name="textSeparator" />.
/// </summary>
Expand Down
36 changes: 36 additions & 0 deletions Source/aweXpect.Core/Core/Helpers/AsyncBecauseReason.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System;
using System.Threading.Tasks;
using aweXpect.Core.Constraints;

namespace aweXpect.Core.Helpers;

internal struct AsyncBecauseReason(Task<string> reason) : IBecauseReason
{
private string? _message;

private static string CreateMessage(string reason)
{
const string prefix = "because";
string message = reason.Trim();

return !message.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
? $", {prefix} {message}"
: $", {message}";
}

#if NET8_0_OR_GREATER
public async ValueTask<ConstraintResult>
#else
public async Task<ConstraintResult>
#endif
ApplyTo(ConstraintResult result)
{
if (_message is null)
{
_message = CreateMessage(await reason.ConfigureAwait(false));
}

string message = _message;
return result.AppendExpectationText(e => e.Append(message));
}
}
18 changes: 14 additions & 4 deletions Source/aweXpect.Core/Core/Helpers/BecauseReason.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
using System;
using System.Threading.Tasks;
using aweXpect.Core.Constraints;

namespace aweXpect.Core.Helpers;

internal readonly struct BecauseReason(string reason)
internal readonly struct BecauseReason(string reason) : IBecauseReason
{
private readonly Lazy<string> _message = new(() => CreateMessage(reason));

Expand All @@ -19,10 +20,19 @@ private static string CreateMessage(string reason)

public override string ToString()
=> _message.Value;

public ConstraintResult ApplyTo(ConstraintResult result)

#if NET8_0_OR_GREATER
public ValueTask<ConstraintResult>
#else
public Task<ConstraintResult>
#endif
ApplyTo(ConstraintResult result)
{
string message = _message.Value;
return result.AppendExpectationText(e => e.Append(message));
#if NET8_0_OR_GREATER
return ValueTask.FromResult(result.AppendExpectationText(e => e.Append(message)));
#else
return Task.FromResult(result.AppendExpectationText(e => e.Append(message)));
#endif
}
}
15 changes: 15 additions & 0 deletions Source/aweXpect.Core/Core/Helpers/IBecauseReason.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Threading.Tasks;
using aweXpect.Core.Constraints;

namespace aweXpect.Core.Helpers;

internal interface IBecauseReason
{

Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove unnecessary blank line inside the interface definition.

Suggested change

Copilot uses AI. Check for mistakes.
#if NET8_0_OR_GREATER
public ValueTask<ConstraintResult>
#else
public Task<ConstraintResult>
#endif
ApplyTo(ConstraintResult result);
}
4 changes: 2 additions & 2 deletions Source/aweXpect.Core/Core/Nodes/AndNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ public override async Task<ConstraintResult> IsMetBy<TValue>(TValue? value,
return combinedResult!;
}

/// <inheritdoc />
public override void SetReason(BecauseReason becauseReason)
/// <inheritdoc cref="Node.SetReason(IBecauseReason)" />
public override void SetReason(IBecauseReason becauseReason)
{
if (_nodes.Any() && Current is ExpectationNode expectationNode && expectationNode.IsEmpty())
{
Expand Down
15 changes: 8 additions & 7 deletions Source/aweXpect.Core/Core/Nodes/ExpectationNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ internal class ExpectationNode : Node

private Node? _inner;

private BecauseReason? _reason;
private IBecauseReason? _reason;

/// <inheritdoc />
public override void AddConstraint(IConstraint constraint)
Expand Down Expand Up @@ -85,22 +85,23 @@ public override async Task<ConstraintResult> IsMetBy<TValue>(TValue? value,
if (_constraint is IValueConstraint<TValue?> valueConstraint)
{
result = valueConstraint.IsMetBy(value);
result = _reason?.ApplyTo(result) ?? result;
}
else if (_constraint is IContextConstraint<TValue?> contextConstraint)
{
result = contextConstraint.IsMetBy(value, context);
result = _reason?.ApplyTo(result) ?? result;
}
else if (_constraint is IAsyncConstraint<TValue?> asyncConstraint)
{
result = await asyncConstraint.IsMetBy(value, cancellationToken);
result = _reason?.ApplyTo(result) ?? result;
}
else if (_constraint is IAsyncContextConstraint<TValue?> asyncContextConstraint)
{
result = await asyncContextConstraint.IsMetBy(value, context, cancellationToken);
result = _reason?.ApplyTo(result) ?? result;
}

if (_reason is not null && result is not null)
{
result = await _reason.ApplyTo(result);
}
}
catch (Exception e) when (e is not ArgumentException && _constraint is not null)
Expand All @@ -123,8 +124,8 @@ public override async Task<ConstraintResult> IsMetBy<TValue>(TValue? value,
.LogTrace();
}

/// <inheritdoc />
public override void SetReason(BecauseReason becauseReason) => _reason = becauseReason;
/// <inheritdoc cref="Node.SetReason(IBecauseReason)" />
public override void SetReason(IBecauseReason becauseReason) => _reason = becauseReason;

public override void AppendExpectation(StringBuilder stringBuilder, string? indentation = null)
{
Expand Down
2 changes: 1 addition & 1 deletion Source/aweXpect.Core/Core/Nodes/Node.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public abstract Task<ConstraintResult> IsMetBy<TValue>(
/// <summary>
/// Set the <paramref name="becauseReason" /> on the current node.
/// </summary>
public abstract void SetReason(BecauseReason becauseReason);
public abstract void SetReason(IBecauseReason becauseReason);

/// <summary>
/// Appends the expectation to the <paramref name="stringBuilder" />.
Expand Down
4 changes: 2 additions & 2 deletions Source/aweXpect.Core/Core/Nodes/OrNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ public override async Task<ConstraintResult> IsMetBy<TValue>(TValue? value,
return combinedResult!;
}

/// <inheritdoc />
public override void SetReason(BecauseReason becauseReason)
/// <inheritdoc cref="Node.SetReason(IBecauseReason)" />
public override void SetReason(IBecauseReason becauseReason)
{
if (_nodes.Any() && Current is ExpectationNode expectationNode && expectationNode.IsEmpty())
{
Expand Down
4 changes: 2 additions & 2 deletions Source/aweXpect.Core/Core/Nodes/WhichNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,8 @@ private static ConstraintResult CombineResults(ConstraintResult? leftResult,
value);
}

/// <inheritdoc />
public override void SetReason(BecauseReason becauseReason)
/// <inheritdoc cref="Node.SetReason(IBecauseReason)" />
public override void SetReason(IBecauseReason becauseReason)
=> _inner?.SetReason(becauseReason);

/// <inheritdoc />
Expand Down
20 changes: 20 additions & 0 deletions Source/aweXpect.Core/Results/ExpectationResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ public ExpectationResult Because(string reason)
return this;
}

/// <summary>
/// Provide an <see langword="async" /> <paramref name="reason" /> explaining why the constraint is needed.<br />
/// If the phrase does not start with the word <i>because</i>, it is prepended automatically.
/// </summary>
public ExpectationResult Because(Task<string> reason)
{
expectationBuilder.AddReason(reason);
return this;
}

/// <summary>
/// Sets the <see cref="CancellationToken" /> to be passed to expectations.
/// </summary>
Expand Down Expand Up @@ -163,6 +173,16 @@ public TSelf Because(string reason)
return (TSelf)this;
}

/// <summary>
/// Provide an <see langword="async" /> <paramref name="reason" /> explaining why the constraint is needed.<br />
/// If the phrase does not start with the word <i>because</i>, it is prepended automatically.
/// </summary>
public TSelf Because(Task<string> reason)
{
expectationBuilder.AddReason(reason);
return (TSelf)this;
}

/// <summary>
/// By awaiting the result, the expectations are verified.
/// <para />
Expand Down
1 change: 0 additions & 1 deletion Tests/aweXpect.Core.Api.Tests/ApiAcceptance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ public sealed class ApiAcceptance
/// Execute this test to update the expected public API to the current API surface.
/// </summary>
[TestCase]
[Explicit]
public async Task AcceptApiChanges()
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removal of the [Explicit] attribute enables this test to run automatically. This test updates the expected public API to match the current API surface, which should typically require manual intervention. Ensure this change is intentional and that the API changes in this PR have been reviewed and approved before allowing automatic execution of this test.

Copilot uses AI. Check for mistakes.
{
string[] assemblyNames =
Expand Down
84 changes: 73 additions & 11 deletions Tests/aweXpect.Core.Tests/Core/BecauseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,74 @@ namespace aweXpect.Core.Tests.Core;

public class BecauseTests
{
[Fact]
public async Task ActionDelegate_ShouldApplyAsyncBecauseReason()
{
string because = "this is the reason";
Task<string> becauseTask = Task.Delay(5).ContinueWith(_ => because);
Action subject = () => throw new MyException();

async Task Act()
{
await That(subject).DoesNotThrow().Because(becauseTask);
}

await That(Act).ThrowsException().WithMessage($"*{because}*").AsWildcard();
}

[Fact]
public async Task ActionDelegate_ShouldApplyBecauseReason()
{
string because = "this is the reason";
Action subject = () => throw new MyException();

async Task Act()
{
await That(subject).DoesNotThrow().Because(because);
}

await That(Act).ThrowsException().WithMessage($"*{because}*").AsWildcard();
}

[Fact]
public async Task ASpecifiedBecauseReason_ShouldBeIncludedInMessage()
{
string because = "I want to test 'because'";
bool subject = true;

async Task Act()
=> await That(subject).IsFalse().Because(because);
{
await That(subject).IsFalse().Because(because);
}

await That(Act).ThrowsException().WithMessage($"*{because}*").AsWildcard();
}

[Fact]
public async Task Delegate_ShouldApplyBecauseReason()
public async Task FuncDelegate_ShouldApplyAsyncBecauseReason()
{
string because = "this is the reason";
Action subject = () => throw new MyException();
Task<string> becauseTask = Task.Delay(5).ContinueWith(_ => because);
Func<int> subject = () => throw new MyException();

async Task Act()
{
await That(subject).DoesNotThrow().Because(becauseTask);
}

await That(Act).ThrowsException().WithMessage($"*{because}*").AsWildcard();
}

[Fact]
public async Task FuncDelegate_ShouldApplyBecauseReason()
{
string because = "this is the reason";
Func<int> subject = () => throw new MyException();

async Task Act()
=> await That(subject).DoesNotThrow().Because(because);
{
await That(subject).DoesNotThrow().Because(because);
}

await That(Act).ThrowsException().WithMessage($"*{because}*").AsWildcard();
}
Expand All @@ -37,7 +85,9 @@ public async Task ShouldPrefixReasonWithBecause(string because, string expectedW
bool subject = true;

async Task Act()
=> await That(subject).IsFalse().Because(because);
{
await That(subject).IsFalse().Because(because);
}

await That(Act).ThrowsException().WithMessage($"*{expectedWithPrefix}*")
.AsWildcard();
Expand All @@ -51,8 +101,10 @@ public async Task WhenApplyBecauseReasonMultipleTimes_ShouldNotOverwritePrevious
bool subject = false;

async Task Act()
=> await That(subject).IsTrue().Because(because1)
{
await That(subject).IsTrue().Because(because1)
.And.IsFalse().Because(because2);
}

await That(Act).ThrowsException().WithMessage($"*{because1}*").AsWildcard();
}
Expand All @@ -65,8 +117,10 @@ public async Task WhenCombineWithAnd_ShouldApplyBecauseReason()
bool subject = true;

async Task Act()
=> await That(subject).IsTrue().Because(because1)
{
await That(subject).IsTrue().Because(because1)
.And.IsFalse().Because(because2);
}

await That(Act).ThrowsException().WithMessage($"*{because2}*").AsWildcard();
}
Expand All @@ -78,8 +132,10 @@ public async Task WhenCombineWithAnd_ShouldApplyBecauseReasonOnlyOnPreviousConst
bool subject = true;

async Task Act()
=> await That(subject).IsTrue().Because(because)
{
await That(subject).IsTrue().Because(because)
.And.IsFalse();
}

await That(Act).ThrowsException()
.WithMessage("""
Expand All @@ -97,8 +153,10 @@ public async Task WhenCombineWithOr_ShouldApplyBecauseReason()
bool subject = true;

async Task Act()
=> await That(subject).IsFalse().Because(because1)
{
await That(subject).IsFalse().Because(because1)
.Or.IsFalse().Because(because2);
}

await That(Act).ThrowsException().WithMessage($"*{because1}*{because2}*")
.AsWildcard();
Expand All @@ -110,7 +168,9 @@ public async Task WhenNoBecauseReasonIsGiven_ShouldNotIncludeBecause()
bool subject = true;

async Task Act()
=> await That(subject).IsFalse();
{
await That(subject).IsFalse();
}

await That(Act).ThrowsException()
.WithMessage("""
Expand All @@ -127,7 +187,9 @@ public async Task WhenReasonStartsWithBecause_ShouldHonorExistingPrefix()
bool subject = true;

async Task Act()
=> await That(subject).IsFalse().Because(because);
{
await That(subject).IsFalse().Because(because);
}

Exception exception = await That(Act).ThrowsException()
.WithMessage("*because*").AsWildcard();
Expand Down
Loading
Loading