Collections
An expectation on a collection reads the subject only once, also together with other expectations, and can verify its
items with the quantifiers of aweXpect, such as All() or AtLeast(2).
Namespaces used on this page
using System.Text;
using aweXpect.Core;
using aweXpect.Core.Constraints;
using aweXpect.Core.EvaluationContext;
using aweXpect.Core.Extending;
using aweXpect.Formatting;
using aweXpect.Options;
using aweXpect.Results;
using static aweXpect.Formatting.Format;
Collection subjects
A constraint that enumerates a collection subject gets it from the IEvaluationContext with
UseMaterializedEnumerable instead of enumerating the subject itself. All expectations on the subject, including the
built-in ones, then share one lazily materialized copy, so that a subject which can only be enumerated once, e.g. a
query or an iterator with side effects, is enumerated at most once, also when the expectations are combined with
.And or .Or. Implement IContextConstraint<T> to receive the context:
public static AndOrResult<IEnumerable<Track>, IThat<IEnumerable<Track>?>> HasRadioFriendlyTracks(
this IThat<IEnumerable<Track>?> subject, int expected)
=> new(subject.Get().ExpectationBuilder.AddConstraint((it, grammars)
=> new HasRadioFriendlyTracksConstraint(it, grammars, expected)),
subject);
private sealed class HasRadioFriendlyTracksConstraint(string it, ExpectationGrammars grammars, int expected)
: ConstraintResult.WithNotNullValue<IEnumerable<Track>>(it, grammars),
IContextConstraint<IEnumerable<Track>?>
{
private int _count;
public ConstraintResult IsMetBy(IEnumerable<Track>? actual, IEvaluationContext context)
{
Actual = actual;
if (actual is not null)
{
_count = context.UseMaterializedEnumerable(actual)
.Count(track => track.Duration <= TimeSpan.FromMinutes(3));
Outcome = _count == expected ? Outcome.Success : Outcome.Failure;
}
return this;
}
protected override void AppendNormalExpectation(StringBuilder stringBuilder, string? indentation = null)
=> stringBuilder.Append("has ").Append(expected).Append(" radio friendly tracks");
protected override void AppendNormalResult(StringBuilder stringBuilder, string? indentation = null)
=> stringBuilder.Append(It).Append(" had ").Append(_count).Append(" radio friendly tracks");
protected override void AppendNegatedExpectation(StringBuilder stringBuilder, string? indentation = null)
=> stringBuilder.Append("does not have ").Append(expected).Append(" radio friendly tracks");
protected override void AppendNegatedResult(StringBuilder stringBuilder, string? indentation = null)
=> stringBuilder.Append(It).Append(" did");
}
await That(tracks).IsNotEmpty().And.HasRadioFriendlyTracks(3) then enumerates tracks only once.
For an IAsyncEnumerable<T>, implement IAsyncContextConstraint<T> and call UseMaterializedAsyncEnumerable with its
CancellationToken.
How the materialized collection behaves
- Every call for the same subject in the same evaluation returns the same sequence. It reads the subject only as far as it is enumerated, and a further enumeration replays the items read so far before it continues the subject. Once the expectation and its failure message are complete, the enumerator of the subject is disposed, also when it was not read to its end.
- A subject that already is a collection is returned unchanged. For any other subject, an exception while it is enumerated fails the expectation like one of the code of the caller.
- An
IAsyncEnumerable<T>stays governed by the token of the first call toUseMaterializedAsyncEnumerable.
Expectations on collection items
An expectation on the items of a collection, i.e. an extension method on IEnumerableItems<TItem>, the items that
All(), AtLeast(2) and the other quantifiers select, derives from QuantifiedCollectionConstraint<TCollection, TItem>.
IEnumerableItems<TItem> gives access to the quantifier and the subject. The built-in expectations on the items, like
Satisfy or AreEqualTo, are such extension methods as well. For a condition on a single item,
[GenerateCollectionExpectation] generates such an expectation.
public static AndOrResult<IEnumerable<Track>, IThat<IEnumerable<Track>?>> AreRadioFriendly(
this IEnumerableItems<Track> items)
{
return new(items.Subject.Get().ExpectationBuilder.AddConstraint((it, grammars)
=> new AreRadioFriendlyConstraint(it, grammars, items.Quantifier)),
items.Subject);
}
private sealed class AreRadioFriendlyConstraint(
string it,
ExpectationGrammars grammars,
EnumerableQuantifier quantifier)
: QuantifiedCollectionConstraint<IEnumerable<Track>?, Track>(it, grammars, quantifier,
g => g.IsPlural() ? "are radio friendly" : "is radio friendly", "were"),
IContextConstraint<IEnumerable<Track>?>
{
public ConstraintResult IsMetBy(IEnumerable<Track>? actual, IEvaluationContext context)
{
StartEvaluation();
Actual = actual;
if (actual is not null)
{
foreach (Track track in context.UseMaterializedEnumerable(actual))
{
Record(track, track.Duration <= TimeSpan.FromMinutes(3));
}
Complete();
}
return this;
}
}
Track[] tracks = [new("Love Me Do", new TimeSpan(0, 2, 22)), new("She Loves You", new TimeSpan(0, 2, 21))];
await That(tracks).All().AreRadioFriendly();
StartEvaluationforgets the items of an earlier evaluation, as the constraint is evaluated again, e.g. byEventually()or for each item of an outer collection.Recordclassifies an item as matching or not matching, andCompletedecides the outcome from the quantifier.- The expectation text is for a single item and is never negated, as the quantifier carries the negation. The
Pluralgrammar asks for the plural form, e.g. in "whose Tracks are radio friendly for at least 2 items". - The verb completes the result, e.g. "but only 1 of 3 were".
- The other collections have their own interfaces:
INonGenericEnumerableItems<TCollection>for a non-genericIEnumerable,IStructEnumerableItems<TCollection, TItem>for anImmutableArrayandIAsyncEnumerableItems<TItem>for anIAsyncEnumerable. Strings have no interface of their own: an expectation only for strings extendsIEnumerableItems<string?>. - The interfaces are covariant in
TItem, so an expectation on the items ofTrack?also applies to the items of aList<Track>or of a collection of a type derived fromTrack. ExtendIEnumerableItems<Track?>to cover both nullabilities.
The base class renders the quantifier like the built-in Satisfy, also when negated or nested, and adds the matching
or not matching items as context.
DoesNotComplyWith(t => t.AtLeast(2).AreRadioFriendly()) then reads "is radio friendly for fewer than 2 items, but 3
of 3 were", followed by the "Matching items". Unlike the built-in expectations, it does not add the "Collection"
context.
Early completion, nested item results, the base class and overloads
- The quantifiers return the derived
IQuantifiedEnumerableItems<TItem>, which only adds the type checksAre<TType>()andAreExactly<TType>(), whose type argument cannot be inferred. - Overload resolution prefers an expectation on
IEnumerableItems<string?>over one onIEnumerableItems<TItem>. - Call
CompleteEarly()instead ofComplete()as soon asIsDetermined, to stop reading the items once they decide the outcome. - When the items are verified by nested expectations, record the result of each item with
Record(item, itemResult), so that the contexts of the item that explains the failure are shown as well, labeled with its index, e.g.Actual (item [2]):. - An item result with
Outcome.FailureBothWaysis neither matching nor not matching, e.g. because the nested expectations threw. Like the built-inComplyWith,Record(item, itemResult)stops at such an item and fails with its result and the contexts of the item, also when negated. An item result withOutcome.Undecided, e.g. after a canceled evaluation, leaves the outcome undecided. Neither changes an outcome that the items before already determine:IsDeterminedis thentrue, and the remaining items are not counted. - When the item expectation or the verb are only known while the failure message is created, e.g. because they come
from nested expectations, derive from
QuantifiedCollectionConstraintBase<TCollection, TItem>and implementAppendItemExpectationandVerbinstead.
Nested expectations on items
When the caller passes expectations for the items, like the built-in ComplyWith, evaluate them with a
ManualExpectationBuilder<TItem> and record the result of each item:
public static AndOrResult<IEnumerable<Track>, IThat<IEnumerable<Track>?>> AreTracksThat(
this IEnumerableItems<Track> items, Action<IThatSubject<Track>> expectations)
=> new(items.Subject.Get().ExpectationBuilder.AddConstraint((it, grammars)
=> new AreTracksThatConstraint(it, grammars, items.Quantifier, expectations)),
items.Subject);
private sealed class AreTracksThatConstraint
: QuantifiedCollectionConstraintBase<IEnumerable<Track>?, Track>,
IAsyncContextConstraint<IEnumerable<Track>?>,
IExpectationTextConstraint
{
private readonly ManualExpectationBuilder<Track> _itemExpectations;
public AreTracksThatConstraint(string it, ExpectationGrammars grammars, EnumerableQuantifier quantifier,
Action<IThatSubject<Track>> expectations)
: base(it, grammars, quantifier)
{
_itemExpectations = new ManualExpectationBuilder<Track>(grammars);
expectations(new ThatSubject<Track>(_itemExpectations));
}
protected override string Verb => _itemExpectations.GetResultVerb();
public async ValueTask<ConstraintResult> IsMetBy(IEnumerable<Track>? actual, IEvaluationContext context,
CancellationToken cancellationToken)
{
StartEvaluation();
Actual = actual;
await _itemExpectations.PrepareExpectation(context, cancellationToken);
if (actual is not null)
{
foreach (Track track in context.UseMaterializedEnumerable(actual))
{
Record(track, await _itemExpectations.IsMetBy(track, context, cancellationToken));
}
Complete();
}
return this;
}
public async ValueTask<ConstraintResult> GetExpectationResult(IEvaluationContext context,
CancellationToken cancellationToken)
{
await _itemExpectations.PrepareExpectation(context, cancellationToken);
return this;
}
protected override void AppendItemExpectation(StringBuilder stringBuilder, ExpectationGrammars grammars,
string? indentation)
=> _itemExpectations.AppendExpectation(stringBuilder, indentation);
protected override void AppendNormalExpectation(StringBuilder stringBuilder, string? indentation = null)
{
base.AppendNormalExpectation(stringBuilder, indentation);
_itemExpectations.AppendReasons(stringBuilder);
}
protected override void AppendNegatedExpectation(StringBuilder stringBuilder, string? indentation = null)
{
base.AppendNegatedExpectation(stringBuilder, indentation);
_itemExpectations.AppendReasons(stringBuilder);
}
}
Track[] playlist = [new("Love Me Do", new TimeSpan(0, 2, 22)), new("She Loves You", new TimeSpan(0, 2, 21))];
await That(playlist).All().AreTracksThat(track => track.IsRadioFriendly());
PrepareExpectationwrites the text of the nested expectations without evaluating them, so that it is complete also when no item is evaluated, e.g. for an empty or anullcollection: otherwise a nestedDoesNotComplyWithis shown without its negation, and the reasons that must be awaited are missing. Call it at the start of each evaluation and inGetExpectationResultofIExpectationTextConstraint, which is called when only the expectation text is needed.AppendReasonsadds the reasons of the nested expectations after the quantifier, e.g. "for all items, because …".
The evaluation context of each item
Each item is evaluated with its own values in the IEvaluationContext, while the materialized collections are shared
with the whole evaluation.
Expected collections
A collection that the caller passes as the expected value, e.g. the titles a playlist must contain, can also be a
query that may only be enumerated once. Enumerate it only once per evaluation: copy it at the start of IsMetBy
unless it already is a collection, and use only the copy afterwards, also for the failure message:
public ConstraintResult IsMetBy(IEnumerable<Track>? actual, IEvaluationContext context)
{
Actual = actual;
ICollection<string> titles = expected as ICollection<string> ?? expected.ToArray();
if (actual is not null)
{
_missingTitles = titles
.Except(context.UseMaterializedEnumerable(actual).Select(track => track.Title))
.ToList();
Outcome = _missingTitles.Count == 0 ? Outcome.Success : Outcome.Failure;
}
return this;
}
The subject itself still goes through UseMaterializedEnumerable, so that it is shared with the other expectations
on it, while the expected collection belongs to your constraint alone.
Matching collections
To compare items with an expected collection like the collection IsEqualTo does, get a matcher from
CollectionMatchOptions and let VerifyItems run it over the items. It returns the outcome together with the failure
text of IsEqualTo, e.g. it lacked 1 of 2 expected items: "Outro", so the caller's choice of InAnyOrder(),
IgnoringDuplicates() or IgnoringCase() applies to your expectation as well:
public static StringCollectionMatchResult<IEnumerable<Track>, IThat<IEnumerable<Track>?>> HasTitles(
this IThat<IEnumerable<Track>?> subject, params string[] expected)
{
OptionsHolder<StringEqualityOptions> options = new(StringEqualityOptions.Default);
OptionsHolder<CollectionMatchOptions> matchOptions = new(CollectionMatchOptions.Default);
return new(subject.Get().ExpectationBuilder.AddConstraint((it, grammars)
=> new HasTitlesConstraint(it, grammars, expected, options, matchOptions)),
subject, options, matchOptions);
}
private sealed class HasTitlesConstraint(
string it,
ExpectationGrammars grammars,
string[] expected,
IReadOnlyOptionsHolder<StringEqualityOptions> options,
IReadOnlyOptionsHolder<CollectionMatchOptions> matchOptions)
: ConstraintResult.WithNotNullValue<IEnumerable<Track>>(it, grammars),
IAsyncContextConstraint<IEnumerable<Track>?>
{
private string? _failure;
public async ValueTask<ConstraintResult> IsMetBy(IEnumerable<Track>? actual, IEvaluationContext context,
CancellationToken cancellationToken)
{
Actual = actual;
_failure = null;
foreach (string title in expected)
{
options.Value.ValidateExpected(title, nameof(expected));
}
if (actual is null)
{
Outcome = Outcome.Failure;
return this;
}
IEnumerable<string?> titles = context.UseMaterializedEnumerable(actual).Select(track => track.Title);
(Outcome, _failure) = await matchOptions.Value.GetCollectionMatcher<string?, string?>(expected)
.VerifyItems(It, titles, options.Value.ForEvaluation(context, cancellationToken), cancellationToken);
return this;
}
protected override void AppendNormalExpectation(StringBuilder stringBuilder, string? indentation = null)
=> stringBuilder.Append("has titles that ").Append(matchOptions.Value.GetExpectation(
Formatter.Format(expected, FormattingOptions.SingleLine) + options.Value, ExpectationGrammars.Plural));
protected override void AppendNormalResult(StringBuilder stringBuilder, string? indentation = null)
=> stringBuilder.Append(_failure);
protected override void AppendNegatedExpectation(StringBuilder stringBuilder, string? indentation = null)
=> stringBuilder.Append("has titles that ").Append(matchOptions.Value.GetExpectation(
Formatter.Format(expected, FormattingOptions.SingleLine) + options.Value,
ExpectationGrammars.Plural | ExpectationGrammars.Negated));
protected override void AppendNegatedResult(StringBuilder stringBuilder, string? indentation = null)
=> stringBuilder.Append(It).Append(" did");
}
await That(tracks).HasTitles("Intro", "Outro").InAnyOrder() then fails with e.g.
but it contained item "Bonus" at index 1 that was not expected.
Validate every expected item with ValidateExpected(item, nameof(expected)) of the options before you look at the
subject, as above, so that an item the options cannot use, e.g. an invalid regex pattern after AsRegex(), throws
whichever items the subject has. A custom match type decides which items it
rejects.
How the items are verified
- The items are verified one by one until the matcher fails or is determined, e.g. once all expected items of a containment are found, and then completed. While the matcher and the comparison answer synchronously, the result completes synchronously, too.
- An exception while the items are enumerated fails the expectation like one of the code of the caller.
- The outcome stays
Undecidedwhen theCancellationTokenis canceled before all items are read from their source. A collection that is complete in memory, e.g. an array, is verified completely. VerifyItemstakes anIEnumerable<T>, so read the items of anIAsyncEnumerable<T>into a list first.