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
107 changes: 107 additions & 0 deletions TUnit.Assertions.Tests/ListAssertionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,105 @@ public async Task Test_List_HasSingleItem()
await Assert.That(list).HasSingleItem();
}

[Test]
public async Task Test_List_HasSingleItem_Item_Allows_Chaining()
{
IList<int> list = new List<int> { 42 };

await Assert.That(list).HasSingleItem().Item.IsEqualTo(42);
}

[Test]
public async Task Test_List_HasSingleItem_Item_Preserves_Preceding_Chain()
{
IList<int> list = new List<int> { 42 };

await Assert.That(list).IsNotEmpty().And.HasSingleItem().Item.IsEqualTo(42);
}

[Test]
public async Task Test_List_HasSingleItem_Item_Does_Not_Reenumerate()
{
var enumerationCount = 0;

IEnumerable<int> GetItems()
{
enumerationCount++;
yield return 42;
}

await Assert.That(GetItems()).HasSingleItem().Item.IsEqualTo(42);
await Assert.That(enumerationCount).IsEqualTo(1);
}

[Test]
public async Task Test_List_HasSingleItem_Item_Preserves_Collection_Shape()
{
IList<List<int>> list = [new List<int> { 1, 2, 3 }];

await Assert.That(list).HasSingleItem().Item.Count().IsEqualTo(3);
}

[Test]
public async Task Test_List_HasSingleItem_Item_Fails_Before_Item_Assertion()
{
IList<int> list = [];

var action = async () => await Assert.That(list).HasSingleItem().Item.IsEqualTo(0);

await Assert.That(action).ThrowsException();
}

[Test]
public async Task Test_List_HasSingleItem_Item_Rejects_Preceding_Or_Chain()
{
var action = async () => await Assert.That(Array.Empty<int>())
.IsEmpty().Or.HasSingleItem().Item.IsEqualTo(0);

await Assert.That(action).Throws<MixedAndOrAssertionsException>();
}

[Test]
public async Task Test_List_HasSingleItem_Item_Or_Cannot_Bypass_Parent_Assertion()
{
var action = async () => await Assert.That(Array.Empty<int>())
.HasSingleItem().Item.IsEqualTo(1).Or.IsEqualTo(0);

await Assert.That(action).ThrowsException();
}

[Test]
public async Task Test_List_HasSingleItem_Item_Preserves_Parent_Through_Mappings()
{
await Assert.That(new[] { "value" }).HasSingleItem().Item.Length().IsEqualTo(5);

var action = async () => await Assert.That(Array.Empty<string>())
.HasSingleItem().Item.Length().IsEqualTo(0);

await Assert.That(action).ThrowsException();
}

[Test]
public async Task Test_List_HasSingleItem_Item_Is_Not_Evaluated_After_Failure_In_Assert_Multiple()
{
var itemAssertionEvaluated = false;

var action = async () =>
{
using (Assert.Multiple())
{
await Assert.That(Array.Empty<int>()).HasSingleItem().Item.Satisfies(_ =>
{
itemAssertionEvaluated = true;
return true;
});
}
};

await Assert.That(action).ThrowsException();
await Assert.That(itemAssertionEvaluated).IsFalse();
}

[Test]
public async Task Test_List_HasSingleItem_WithPredicate()
{
Expand All @@ -211,6 +310,14 @@ public async Task Test_List_HasSingleItem_WithPredicate()
await Assert.That(item).IsEqualTo(3);
}

[Test]
public async Task Test_List_HasSingleItem_WithPredicate_Item_Allows_Chaining()
{
IList<int> list = new List<int> { 1, 2, 3, 4, 5 };

await Assert.That(list).HasSingleItem(x => x == 3).Item.IsEqualTo(3);
}

[Test]
public async Task Test_List_HasSingleItem_WithPredicate_Fails_WhenNoneMatch()
{
Expand Down
8 changes: 8 additions & 0 deletions TUnit.Assertions.Tests/MemoryAssertionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ public async Task Test_Memory_HasSingleItem()
await Assert.That(memory).HasSingleItem();
}

[Test]
public async Task Test_Memory_HasSingleItem_Item_Allows_Chaining()
{
Memory<int> memory = new[] { 42 };

await Assert.That(memory).HasSingleItem().Item.IsEqualTo(42);
}

[Test]
public async Task Test_Memory_All()
{
Expand Down
3 changes: 2 additions & 1 deletion TUnit.Assertions.Tests/ParseAssertionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,9 @@ await Assert.That(sut)
}
}).ThrowsException();

// Should have recorded the length assertion failure
// Both the pre-work failure and mapped assertion failure must be recorded.
await Assert.That(exception.Message).Contains("length");
await Assert.That(exception.Message).Contains("456");
}

[Test]
Expand Down
18 changes: 17 additions & 1 deletion TUnit.Assertions/Collections/MemoryAssertions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#if NET5_0_OR_GREATER
using System.Diagnostics.CodeAnalysis;
using TUnit.Assertions.Abstractions;
using TUnit.Assertions.Conditions;
using TUnit.Assertions.Conditions.Helpers;
using TUnit.Assertions.Core;
using TUnit.Assertions.Enums;
Expand Down Expand Up @@ -235,6 +236,7 @@ protected override string GetExpectation() =>
public class MemoryHasSingleItemAssertion<TMemory, TItem> : MemoryAssertionBase<TMemory, TItem>
{
private readonly Func<TMemory, ICollectionAdapter<TItem>> _adapterFactory;
private TItem? _singleItem;

public MemoryHasSingleItemAssertion(
AssertionContext<TMemory> context,
Expand All @@ -259,10 +261,24 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TMemory>
}

var adapter = _adapterFactory(metadata.Value);
return Task.FromResult(CollectionChecks.CheckHasSingleItem(adapter));
return Task.FromResult(CollectionChecks.CheckHasSingleItem(adapter, out _singleItem));
}

protected override string GetExpectation() => "to have a single item";

/// <summary>
/// Drills into the single item, allowing assertions to be chained directly against it.
/// </summary>
public SingleItemSource<TItem> Item
{
get
{
ThrowIfMixingCombiner<Chaining.OrAssertion<TMemory>>();
Context.ExpressionBuilder.Append(".Item");
Context.SetPendingLink(InternalWrappedExecution ?? this, CombinerType.And);
return SingleItemSource<TItem>.Create(Context, _ => _singleItem);
}
}
}

/// <summary>
Expand Down
28 changes: 28 additions & 0 deletions TUnit.Assertions/Conditions/CollectionAssertions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,20 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TCollecti

protected override string GetExpectation() => "to have exactly one item";

/// <summary>
/// Drills into the single item, allowing assertions to be chained directly against it.
/// </summary>
public SingleItemSource<TItem> Item
{
get
{
ThrowIfMixingCombiner<Chaining.OrAssertion<TCollection>>();
Context.ExpressionBuilder.Append(".Item");
Context.SetPendingLink(InternalWrappedExecution ?? this, CombinerType.And);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Guard .Item against existing Or chains

When .Item is reached after an Or chain (for example Assert.That(Array.Empty<int>()).IsEmpty().Or.HasSingleItem().Item.IsEqualTo(0)), this unconditionally wraps the existing OrAssertion in an And link. Because OrAssertion can short-circuit without executing HasSingleItem, _singleItem remains default, so the item assertion can pass even though no single item was captured. This should reject the mixed Or/drill-in case, like the normal .And path does, before installing the pending And link.

Useful? React with 👍 / 👎.

return SingleItemSource<TItem>.Create(Context, _ => _singleItem);
}
}

/// <summary>
/// Enables await syntax that returns the single item.
/// This allows both chaining (.And) and item capture (await).
Expand Down Expand Up @@ -560,6 +574,20 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TCollecti

protected override string GetExpectation() => $"to have exactly one item matching {_predicateDescription}";

/// <summary>
/// Drills into the single matching item, allowing assertions to be chained directly against it.
/// </summary>
public SingleItemSource<TItem> Item
{
get
{
ThrowIfMixingCombiner<Chaining.OrAssertion<TCollection>>();
Context.ExpressionBuilder.Append(".Item");
Context.SetPendingLink(InternalWrappedExecution ?? this, CombinerType.And);
return SingleItemSource<TItem>.Create(Context, _ => _singleItem);
}
}

/// <summary>
/// Enables await syntax that returns the matching item.
/// This allows both chaining (.And) and item capture (await).
Expand Down
27 changes: 27 additions & 0 deletions TUnit.Assertions/Conditions/SingleItemSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using TUnit.Assertions.Core;
using TUnit.Assertions.Sources;

namespace TUnit.Assertions.Conditions;

/// <summary>
/// Assertion source for the item captured by a successful <c>HasSingleItem</c> assertion.
/// </summary>
/// <typeparam name="TItem">The collection item type.</typeparam>
[global::TUnit.Assertions.Attributes.GenerateCollectionShapeAssertions]
public sealed class SingleItemSource<TItem> : ValueAssertion<TItem>
{
internal SingleItemSource(AssertionContext<TItem> context)
: base(context)
{
}

internal static SingleItemSource<TItem> Create<TValue>(
AssertionContext<TValue> context,
Func<TValue?, TItem?> mapper)
{
var itemContext = context.Map(mapper);
itemContext.PreservePendingPreWorkOnMap = true;
itemContext.SkipAssertionOnPreWorkFailure = true;
return new SingleItemSource<TItem>(itemContext);
}
}
29 changes: 24 additions & 5 deletions TUnit.Assertions/Core/Assertion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ public Assertion<TValue> Because(string message)
/// </summary>
public virtual async Task<TValue?> AssertAsync()
{
if (!await ExecutePendingPreWorkAsync())
{
return default;
}

// If part of an And/Or chain, delegate to the wrapper
if (_wrappedExecution != null)
{
Expand All @@ -129,12 +134,9 @@ public Assertion<TValue> Because(string message)
/// </summary>
internal async Task<TValue?> ExecuteCoreAsync()
{
// Execute any pending cross-type assertions first (e.g., string assertions before WhenParsedInto<int>)
if (Context.PendingPreWork != null)
if (!await ExecutePendingPreWorkAsync())
{
var preWork = Context.PendingPreWork;
Context.PendingPreWork = null; // Clear BEFORE execution to prevent re-entry
await preWork();
return default;
}

// If this is an And/OrAssertion (composite), delegate to AssertAsync which has custom logic
Expand All @@ -155,6 +157,23 @@ public Assertion<TValue> Because(string message)
return contextResult.Value;
}

private async Task<bool> ExecutePendingPreWorkAsync()
{
if (Context.PendingPreWork is not { } preWork)
{
return true;
}

Context.PendingPreWork = null; // Clear before execution to prevent re-entry.
var currentScope = AssertionScope.GetCurrentAssertionScope();
var exceptionCountBefore = currentScope?.ExceptionCount ?? 0;
await preWork();

return !Context.SkipAssertionOnPreWorkFailure
|| currentScope is null
|| currentScope.ExceptionCount <= exceptionCountBefore;
}

// Create EvaluationMetadata in a separate scope to avoid creating additional
// DateTimeOffset fields in the state machine
private Task<AssertionResult> CreateMetadataAndCheckAsync(TValue? value, Exception? exception)
Expand Down
22 changes: 22 additions & 0 deletions TUnit.Assertions/Core/AssertionContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ public AssertionContext<TNew> Map<TNew>(Func<EvaluationContext<TValue>, Evaluati
newContext.PendingPreWork = async () => await pendingAssertion.ExecuteCoreAsync();
}

if (PreservePendingPreWorkOnMap && PendingPreWork is { } preWork)
{
PendingPreWork = null;
var existing = newContext.PendingPreWork;
newContext.PendingPreWork = existing is null
? preWork
: async () => { await preWork(); await existing(); };
newContext.PreservePendingPreWorkOnMap = true;
newContext.SkipAssertionOnPreWorkFailure = SkipAssertionOnPreWorkFailure;
}

return newContext;
}

Expand Down Expand Up @@ -160,6 +171,17 @@ public AssertionContext<TException> MapException<TException>() where TException
/// </summary>
internal Func<Task>? PendingPreWork { get; set; }

/// <summary>
/// Keeps pending pre-work attached while drill-in operations map through intermediate types.
/// </summary>
internal bool PreservePendingPreWorkOnMap { get; set; }

/// <summary>
/// Skips the mapped assertion when pending pre-work fails inside <see cref="Assert.Multiple"/>.
/// Used when the mapped value is only valid after the pre-work succeeds.
/// </summary>
internal bool SkipAssertionOnPreWorkFailure { get; set; }

/// <summary>
/// Sets the pending link state for the next assertion to consume.
/// Called by AndContinuation/OrContinuation constructors.
Expand Down
Loading
Loading