blob: 07aef7ea86b0ee690b5c72bb55e76cb4a95528d6 [file] [edit]
// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'package:meta/meta.dart' as meta;
import 'package:test_api/hooks.dart';
import 'describe.dart';
import 'extensions/async.dart';
import 'extensions/core.dart';
import 'extensions/iterable.dart';
/// A target for checking expectations against a value in a test.
///
/// A subject may have a real value, in which case the expectations can be
/// validated or rejected; or it may be a placeholder, in which case
/// expectations describe what would be checked but cannot be rejected.
///
/// Expectation methods are defined in extensions `on Subject`, specialized on
/// the generic [T].
/// Expectation extension methods can use the [ContextExtension] to interact
/// with the [Context] for this subject.
///
/// Create a subject that throws an exception for missed expectations with the
/// [check] function.
final class Subject<T> {
final Context<T> _context;
Subject._(this._context);
}
extension SkipExtension<T> on Subject<T> {
/// Mark the currently running test as skipped and return a [Subject] that
/// will ignore all expectations.
///
/// Any expectations against the return value will not be checked and will not
/// be included in the "Expected" or "Actual" string representations of a
/// failure.
///
/// ```dart
/// check(actual)
/// ..stillChecked()
/// ..skip('reason the expectation is temporarily not met').notChecked();
/// ```
///
/// If `skip` is invoked by a [Condition] within a call [Condition.softCheck]
/// or [Condition.describe] it will still mark the test as skipped, even
/// though failing the expectation would not have otherwise caused the test to
/// fail.
Subject<T> skip(String message) {
TestHandle.current.markSkipped(message);
return Subject._(_SkippedContext());
}
}
/// Creates a [Subject] that can be used to validate expectations against
/// [value], with an exception upon a failed expectation.
///
/// Expectations that are not satisfied throw a [TestFailure] to interrupt the
/// currently running test and mark it as failed.
///
/// If [because] is passed it will be included as a "Reason:" line in failure
/// messages.
///
/// ```dart
/// check(actual).equals(expected);
/// ```
@meta.useResult
Subject<T> check<T>(T value, {String? because}) => Subject._(
_TestContext._root(
value: _Present(value),
// TODO - switch between "a" and "an"
label: 'a $T',
fail: (f) {
final which = f.rejection.which;
final collapsed = f.detail.collapsedDetails;
final (depth, expectedLines, actualLines) = collapsed != null
? (0, ['Expected: ${collapsed.$1}'], ['Actual: ${collapsed.$2}'])
: (
f.detail.depth,
prefixFirst('Expected: ', f.detail.expected),
[
...prefixFirst('Actual: ', f.detail.actual),
if (!f.detail.hasCustomActual)
...indent(
prefixFirst('Actual: ', f.rejection.actual),
f.detail.depth,
),
],
);
throw TestFailure(
[
...expectedLines,
...actualLines,
if (which != null && which.isNotEmpty)
...indent(prefixFirst('Which: ', which), depth),
if (because != null) 'Reason: $because',
].join('\n'),
);
},
allowAsync: true,
allowUnawaited: true,
),
);
/// A set of expectations that can be checked against a [Subject].
///
/// Conditions which have asynchronous interactions will return a [Future] from
/// [apply], [softCheck], and [describe]. Conditions which have had only
/// synchronous expectations will return a synchronous result from these
/// methods.
///
/// Synchronous variants [applySync], [softCheckSync], and [describeSync] return
/// non-[Future] values always, but will throw an [AsyncConditionDisallowed]
/// exception if asynchronous expectations were invoked on the condition.
abstract final class Condition<T> {
static ConditionSubject<T> it<T>() => ._();
/// Run these expectations against [subject].
FutureOr<void> apply(Subject<T> subject);
/// Run these expectations against [subject].
///
/// Asynchronous expectations are not allowed and will cause a runtime error
/// if they were used.
void applySync(Subject<T> subject);
/// Checks whether [value] satisfies all expectations invoked in this
/// condition, without throwing an exception for failed expectations.
///
/// The future will complete to `null` if all expectations are satisfied,
/// otherwise it will complete to the [CheckFailure] for the first expectation
/// that fails.
FutureOr<CheckFailure?> softCheck(T value);
/// Checks whether [value] satisfies all expectations invoked in this
/// condition, without throwing an exception for failed expectations.
///
/// The future will complete to `null` if all expectations are satisfied,
/// otherwise it will complete to the [CheckFailure] for the first expectation
/// that fails.
///
/// Asynchronous expectations are not allowed and will cause a runtime error
/// if they were used.
CheckFailure? softCheckSync(T value);
/// Creates a description of the expectations invoked in this condition.
///
/// The strings are individual lines of a description.
/// The description of an expectation may be one or more adjacent lines.
///
/// Matches the "Expected: " lines in the output of a failure message if a
/// value did not meet the last expectation in this condition, without the
/// first labeled line.
///
/// This method should not throw exxceptions.
FutureOr<Iterable<String>> describe();
/// Creates a description of the expectations invoked in this condition.
///
/// The strings are individual lines of a description.
/// The description of an expectation may be one or more adjacent lines.
///
/// Matches the "Expected: " lines in the output of a failure message if a
/// value did not meet the last expectation in this condition, without the
/// first labeled line.
///
/// Asynchronous expectations are not allowed and will cause a runtime error
/// if they were used.
///
/// This method should not throw exceptions.
Iterable<String> describeSync();
}
/// A [Subject] that records the checks that are performed and expose them as an
/// [Condition].
final class ConditionSubject<T> implements Condition<T>, Subject<T> {
ConditionSubject._();
@override
final _ReplayContext<T> _context = .new();
@override
FutureOr<void> apply(Subject<T> subject) => _context.apply(subject);
@override
void applySync(Subject<T> subject) {
final result = _context.apply(subject);
if (result is Future) throw AsyncConditionDisallowed._('Async');
}
@override
FutureOr<CheckFailure?> softCheck(T value) =>
_context.softCheck(value, allowAsync: true);
@override
CheckFailure? softCheckSync(T value) {
final result = _context.softCheck(value, allowAsync: false);
if (result is Future) throw AsyncConditionDisallowed._('Async');
return result;
}
@override
FutureOr<Iterable<String>> describe() => _context.describe(allowAsync: true);
@override
Iterable<String> describeSync() {
final result = _context.describe(allowAsync: false);
if (result is Future) throw AsyncConditionDisallowed._('Async');
return result;
}
}
extension ContextExtension<T> on Subject<T> {
/// The expectations and nesting context for this subject.
Context<T> get context => _context;
}
/// The context for a [Subject] that allows asserting expectations and creating
/// nested subjects.
///
/// A [Subject] is the target for checking expectations in a test.
/// Every subject has a [Context] which holds the "actual" value, tracks how the
/// value was obtained, and can check expectations about the value.
///
/// The user focused APIs called within tests are expectation extension methods
/// written in an extension `on Subject`, typically specialized to a specific
/// generic.
///
/// Expectation extension methods will make a call to one of the APIs on the
/// subject's [Context], and can perform one of two types of operations:
///
/// - Expect something of the current value (such as [CoreChecks.equals] or
/// [IterableChecks.contains]) by calling [expect], [expectAsync], or
/// [expectUnawaited].
/// - Expect that a new subject can be extracted from the current value (such
/// as [CoreChecks.has] or [FutureChecks.completes]) by calling [nest] or
/// [nestAsync].
///
///
/// Whichever type of operation, an expectation extension method provides two
/// callbacks.
/// The first callback is an `Iterable<String> Function()` returning a
/// description of the expectation.
/// The second callback always takes the actual value as an argument, and the
/// specific signature varies by operation.
///
///
/// In expectation extension methods calling [expect], [expectAsync], or
/// [expectUnawaited], the `predicate` callback can report a [Rejection] if the
/// value fails to satisfy the expectation.
/// The description will be passed in a "clause" callback.
/// {@template clause_description}
/// The clause callback returns a description of what is checked which stands
/// on its own as an indented line under a labeled subject.
/// For instance the `starts with 'a'` in:
///
/// ```
/// Expected: a String that:
/// starts with 'a'
/// ends with 'z'
/// ```
///
/// An expectation can optionally provide a [predicateNoun] callback.
/// [predicateNoun] should return a single-line noun phrase including this
/// expectation modifying the noun (often just a representation of the expected
/// value) to be used when collapsing simple expectations.
///
/// Examples of predicate nouns:
/// - Equality/identity: `literal(expected).singleOrNull` (e.g. `'<2>'`)
/// - Relational checks: `'a value > <2>'`
/// - Adjective/type descriptions: `'an empty iterable'`, `'a negative number'`,
/// `'a string starting with \'a\''`
///
/// If the expected value's representation is multiline or cannot be collapsed,
/// [predicateNoun] should return `null` to disable collapsing.
/// {@endtemplate}
///
/// In expectation extension methods calling [nest] or [nestAsync], the
/// `extract` callback can return a [Extracted.rejection] if the value fails to
/// satisfy an expectation which disallows extracting the value, or an
/// [Extracted.value] to become the value in a nested subject.
/// The description will be passed in a "label" callback.
/// {@template label_description}
/// The label callback returns a description of the extracted subject as it
/// relates to the original subject.
/// For instance the `completes to a value` in:
///
/// ```
/// Expected: a Future<String> that:
/// completes to a value that:
/// starts with 'a'
/// ends with 'z'
/// ```
///
/// A label should also be sensible when it is read as a clause.
/// If no further expectations are checked on the extracted subject, or if the
/// extraction is rejected, the "that:" is omitted in the output:
///
/// ```
/// Expected: a Future<int> that:
/// completes to a value
/// ```
///
/// A nesting context can optionally provide an [addPredicate] callback.
/// [addPredicate] takes a single-line `predicateNoun` string (produced by a
/// nested expectation or deeper child nesting context) and returns a combined
/// single-line description attaching that predicate to this parent subject.
///
/// For example:
/// - `has` uses `(predicateNoun) => 'has $name: $predicateNoun'`
/// - `completes` uses `(predicateNoun) => 'completes to $predicateNoun'`
/// - `throws` uses `(predicateNoun) => 'throws $predicateNoun'`
/// - `operator []` uses `(predicateNoun) => 'has entry <$key: $predicateNoun>'`
///
/// If this level cannot be collapsed (for example, if a property name, key, or
/// value is multiline), [addPredicate] should return `null`.
/// {@endtemplate}
///
///
/// A rejection carries two descriptions, one description of the "actual" value
/// that was tested, and an optional "which" with further details about how the
/// result different from the expectation.
/// If the "actual" argument is omitted it will be filled with a representation
/// of the value passed to the expectation callback formatted with [literal].
/// If an expectation extension method is written on a type of subject without a
/// useful `toString()`, the rejection can provide a string representation to
/// use instead.
/// The "which" argument may be omitted if the reason is very obvious based on
/// the clause and "actual" description, but most expectations should include a
/// "which".
///
/// The behavior of a context following a rejection depends on the source of the
/// [Subject].
///
/// When an expectation is rejected for a [check] subject, an exception is
/// thrown to interrupt the test, so no further checks should happen.
/// The failure messages will include:
/// - An "Expected" section with descriptions of expectations that were
/// checked, potentially including the ones that passed, and always including
/// the last one that failed.
/// - An "Actual" section, which may be the description directly from the
/// [Rejection] if the failure was on the root subject, or may start with a
/// partial version of the "Expected" description up to the label for the
/// nesting subject that saw a failure, then the "actual" from the rejection.
/// - A "Which" description from the rejection, if it was included.
///
/// Failure messages can be presented in one of two formats:
///
/// 1. **Collapsed Single-Line Format**: For simple, single expectations (such as
/// `check(1).equals(2)` or `check(user).name.equals('Alice')`), messages
/// collapse into compact, single-line "Expected:" and "Actual:" lines:
///
/// ```
/// Expected: a User that has name: 'Alice'
/// Actual: a User that has name: 'Bob'
/// Which: is not equal
/// ```
///
/// Single-line collapsing occurs when:
/// - Exactly one expectation is checked at each nesting level (satisfied
/// preconditions checked `atSameLevel`, such as `isNotNull()` or `isA<T>()`,
/// do not count against this limit).
/// - The leaf expectation provides a [predicateNoun] callback that returns a
/// non-null single-line string.
/// - All parent nesting contexts provide an [addPredicate] callback that
/// successfully combines the nested predicate noun into a single-line
/// description.
/// - The combined description at the root context does not exceed the
/// length limit (80 characters for labeled roots).
///
/// 2. **Structured Multi-Line Format**: When multiple expectations are checked
/// on the same subject, when an expectation description spans multiple lines,
/// or when root-level collapsing is not supported, the message uses a
/// hierarchical multi-line format.
///
/// Within this format, individual child properties still collapse whenever
/// possible:
/// - Succeeded child properties collapse to single lines describing the
/// expected condition (e.g. `has length: <2>`).
/// - If the failing expectation was on a nested property that supports
/// collapsing, both the expected condition and the rejected actual value
/// are collapsed to single lines at that indentation level.
///
/// ```
/// Expected: a List<int> that:
/// has length: <2>
/// has first element: <1>
/// Actual: a List<int> that:
/// has length: <2>
/// has first element: <0>
/// Which: is not equal
/// ```
///
/// - When a single nested property is checked on the root subject but its
/// inner expectations are multiline, "Expected" is formatted
/// hierarchically while "Actual" collapses all the way to the root subject:
///
/// ```
/// Expected: a Future<String> that:
/// completes to a value that:
/// starts with 'a'
/// ends with 'z'
/// Actual: a Future<String> that completes to 'abcd'
/// Which: does not end with 'z'
/// ```
///
/// If an expectation does not support collapsing (such as a custom `nest`
/// without an `addPredicate` callback), the "Actual" section will include
/// the expectations that succeeded up to the nesting point of the failure,
/// followed by an indented `Actual:` line with the rejection:
///
/// ```
/// Expected: a Foo that:
/// has someDerivedValue that:
/// starts with 'a'
/// ends with 'z'
/// Actual: a Foo that:
/// has someDerivedValue that:
/// Actual: 'abcd'
/// Which: does not end with 'z'
/// ```
///
/// ```dart
/// extension CustomChecks on Subject<CustomType> {
/// void someExpectation() {
/// context.expect(() => ['meets this expectation'], (actual) {
/// if (_expectationIsMet(actual)) return null;
/// return Rejection(which: ['does not meet this expectation']);
/// });
/// }
///
/// Subject<Foo> get someDerivedValue =>
/// context.nest('has someDerivedValue', (actual) {
/// if (_cannotReadDerivedValue(actual)) {
/// return Extracted.rejection(which: ['cannot read someDerivedValue']);
/// }
/// return Extracted.value(_readDerivedValue(actual));
/// });
///
/// // for field reads that will not get rejected, use `has`
/// Subject<Bar> get someField => has((a) => a.someField, 'someField');
/// }
/// ```
///
/// When an expectation is rejected for a subject within a call to
/// [Condition.softCheck] a [CheckFailure] will be returned with the rejection,
/// as well as a [FailureDetail] which could be used to format the same failure
/// message thrown by the [check] subject.
///
/// {@template callbacks_may_be_unused}
/// The description of an expectation may never be shown to the user, so the
/// callback may never be invoked.
/// If all the conditions on a subject succeed, or if the failure detail for a
/// failed [Condition.softCheck] or [Condition.softCheckSync] is never read,
/// the descriptions will be unused. String formatting for the descriptions
/// should be performed in the callback, not ahead of time.
///
/// The context for a subject may hold a real "actual" value to test against, or
/// it may have a placeholder within a call to [Condition.describe].
/// A context with a placeholder value will not invoke the callback to check
/// expectations.
///
/// If both callbacks are invoked, the description callback will always be
/// called strictly after the expectation callback is called.
///
/// Callbacks passed to a context should not throw.
/// {@endtemplate}
///
///
/// Some contexts disallow certain interactions.
/// {@template async_limitations}
/// Calls to [expectAsync] or [nestAsync] must not be performed on a
/// [Condition].
/// Use [Condition] for any condition which checks async expectations.
/// {@endtemplate}
/// {@template unawaited_limitations}
/// Calls to [expectUnawaited] may not be performed by a [Condition] if the
/// [Condition.softCheck] method is used.
/// {@endtemplate}
///
/// Expectation extension methods can access the context for the subject with
/// the [ContextExtension].
///
/// {@template description_lines}
/// Description callbacks return an `Iterable<String>` where each element is a
/// line in the output. Individual elements should not contain newlines.
/// Utilities such as [prefixFirst], [postfixLast], and [literal] may be useful
/// to format values which are potentially multiline.
/// {@endtemplate}
abstract final class Context<T> {
/// Expect that [predicate] will not return a [Rejection] for the checked
/// value.
///
/// {@macro clause_description}
///
/// {@macro description_lines}
///
/// {@macro callbacks_may_be_unused}
///
/// ```dart
/// void someExpectation() {
/// context.expect(() => ['meets this expectation'], (actual) {
/// if (_expectationIsMet(actual)) return null;
/// return Rejection(which: ['does not meet this expectation']);
/// });
/// }
/// ```
void expect(
Iterable<String> Function() clause,
Rejection? Function(T) predicate, {
String? Function()? predicateNoun,
});
/// Expect that [predicate] will not result in a [Rejection] for the checked
/// value.
///
/// {@macro clause_description}
///
/// {@macro description_lines}
///
/// {@macro callbacks_may_be_unused}
///
/// {@macro async_limitations}
///
/// Extensions which use `expectAsync` should always make that call
/// synchronously and return the result so that exceptions stemming from use
/// in a synchronous context can be reported synchronously.
///
/// ```dart
/// extension CustomChecks on Subject<CustomType> {
/// Future<void> someAsyncExpectation() {
/// return context.expectAsync(() => ['meets this async expectation'],
/// (actual) async {
/// if (await _expectationIsMet(actual)) return null;
/// return Rejection(which: ['does not meet this async expectation']);
/// });
/// }
/// }
/// ```
Future<void> expectAsync(
Iterable<String> Function() clause,
FutureOr<Rejection?> Function(T) predicate, {
String? Function()? predicateNoun,
});
/// Expect that [predicate] will not invoke the passed callback with a
/// [Rejection] at any point.
///
/// In contrast to [expectAsync], a rejection is reported through a
/// callback instead of through a returned Future. The callback may be invoked
/// at any point that the failure surfaces.
///
/// This may be useful for a condition checking that some event _never_
/// happens. If there is no specific point where it is know to be safe to stop
/// listening for the event, there is no way to complete a returned future and
/// consider the check "complete".
///
/// {@macro clause_description}
///
/// {@macro description_lines}
///
/// {@macro callbacks_may_be_unused}
///
/// {@macro unawaited_limitations}
/// The only useful effect of a late rejection is to throw a [TestFailure]
/// when used with a [check] subject. Most conditions should prefer to use
/// [expect] or [expectAsync].
///
/// ```dart
/// void someUnawaitableExpectation() async {
/// await context.expectUnawaited(
/// () => ['meets this unawaitable expectation'], (actual, reject) {
/// final failureSignal = _completeIfFailed(actual);
/// unawaited(failureSignal.then((_) {
/// reject(Reject(
/// which: ['unexpectedly failed this unawaited expectation']));
/// }));
/// });
/// }
/// ```
void expectUnawaited(
Iterable<String> Function() clause,
void Function(T, void Function(Rejection)) predicate, {
String? Function()? predicateNoun,
});
/// Extract a property from the value for further checking.
///
/// If the property cannot be extracted, [extract] should return an
/// [Extracted.rejection] describing the problem. Otherwise it should return
/// an [Extracted.value].
///
/// Subsequent expectations can be checked for the extracted value on the
/// returned [Subject].
///
/// {@macro label_description}
///
/// If [atSameLevel] is true then the returned [Extracted.value] should hold
/// the same instance as the passed value, or an object which is is equivalent
/// but has a type that is more convenient to test.
/// In this case expectations applied to the returned [Subject] will behave as
/// if they were applied to the subject for this context.
/// The [label] will be used as if it were a "clause" argument passed to
/// [expect].
/// If the label returns an empty iterable, the clause will be omitted.
/// The label should only be left empty if the value extraction cannot be
/// rejected.
///
/// {@macro description_lines}
///
/// {@macro callbacks_may_be_unused}
///
/// If [nestedCondition] is passed it will be executed against the resulting
/// `Subject` before returning it.
/// Nesting extensions which are getters can ignore [nestedCondition],
/// and nesting extensions which are methods should prefer to include it.
///
/// ```dart
/// Subject<Foo> someDerivedValue([Condition<Foo>? that]) =>
/// context.nest(() => ['has someDerivedValue'], (actual) {
/// if (_cannotReadDerivedValue(actual)) {
/// return Extracted.rejection(
/// which: ['cannot read someDerivedValue']);
/// }
/// return Extracted.value(_readDerivedValue(actual));
/// }, nestedCondition: that);
/// ```
Subject<R> nest<R>(
Iterable<String> Function() label,
Extracted<R> Function(T) extract, {
Condition<R>? nestedCondition,
bool atSameLevel = false,
String? Function(String predicateNoun)? addPredicate,
});
/// Extract an asynchronous property from the value for further checking.
///
/// If the property cannot be extracted, [extract] should return an
/// [Extracted.rejection] describing the problem. Otherwise it should return
/// an [Extracted.value].
///
/// {@macro label_description}
///
/// {@macro description_lines}
///
/// {@macro callbacks_may_be_unused}
///
/// If [nestedCondition] is passed it will be executed against the resulting
/// `Subject` before returning it.
/// Async nesting extensions which are methods should prefer to include it.
///
/// {@macro async_limitations}
///
/// Extensions which use `nestAsync` should always make that call
/// synchronously and return the result so that exceptions stemming from use
/// in a synchronous context can be reported synchronously.
///
/// ```dart
/// Future<Subject<Result>> someAsyncResult(
/// [Condition<Result>? resultCondition]) {
/// return context.nestAsync(() => ['has someAsyncResult'], (actual) async {
/// if (await _asyncOperationFailed(actual)) {
/// return Extracted.rejection(which: ['cannot read someAsyncResult']);
/// }
/// return Extracted.value(await _readAsyncResult(actual));
/// }, resultCondition);
/// }
/// ```
Future<Subject<R>> nestAsync<R>(
Iterable<String> Function() label,
FutureOr<Extracted<R>> Function(T) extract,
Condition<R>? nestedCondition, {
String? Function(String predicateNoun)? addPredicate,
});
}
/// A property extracted from a value being checked, or a rejection.
final class Extracted<T> {
final Rejection? _rejection;
final T? _value;
/// Creates a rejected extraction to indicate a failure trying to read the
/// value.
///
/// When a nesting is rejected with an omitted or empty [actual] argument, it
/// will be filled in with the [literal] representation of the value.
Extracted.rejection({
Iterable<String> actual = const [],
Iterable<String>? which,
}) : _rejection = Rejection(actual: actual, which: which),
_value = null;
Extracted.value(T this._value) : _rejection = null;
Extracted._(Rejection this._rejection) : _value = null;
Extracted<R> _map<R>(R Function(T) transform) {
final rejection = _rejection;
if (rejection != null) return Extracted._(rejection);
return Extracted.value(transform(_value as T));
}
Extracted<T> _fillActual(Object? actual) =>
_rejection == null || _rejection.actual.isNotEmpty
? this
: Extracted.rejection(actual: literal(actual), which: _rejection.which);
}
abstract interface class _Optional<T> {
R? apply<R extends FutureOr<Rejection?>>(R Function(T) callback);
Future<Extracted<_Optional<R>>> mapAsync<R>(
FutureOr<Extracted<R>> Function(T) transform,
);
Extracted<_Optional<R>> map<R>(Extracted<R> Function(T) transform);
}
class _Present<T> implements _Optional<T> {
final T value;
_Present(this.value);
@override
R? apply<R extends FutureOr<Rejection?>>(R Function(T) c) => c(value);
@override
Future<Extracted<_Present<R>>> mapAsync<R>(
FutureOr<Extracted<R>> Function(T) transform,
) async {
final transformed = await transform(value);
return transformed._map(_Present.new);
}
@override
Extracted<_Present<R>> map<R>(Extracted<R> Function(T) transform) =>
transform(value)._map(_Present.new);
}
class _Absent<T> implements _Optional<T> {
@override
R? apply<R extends FutureOr<Rejection?>>(R Function(T) c) => null;
@override
Future<Extracted<_Absent<R>>> mapAsync<R>(
FutureOr<Extracted<R>> Function(T) transform,
) async => Extracted.value(_Absent<R>());
@override
Extracted<_Absent<R>> map<R>(FutureOr<Extracted<R>> Function(T) transform) =>
Extracted.value(_Absent<R>());
}
final class _TestContext<T> implements Context<T>, _ClauseDescription {
final _Optional<T> _value;
/// A reference to find the root context which this context is nested under.
///
/// null only for the root context.
final _TestContext<dynamic>? _parent;
final List<_ClauseDescription> _clauses;
final List<_TestContext> _aliases;
final void Function(CheckFailure) _fail;
final bool _allowAsync;
final bool _allowUnawaited;
/// A callback that returns a label for this context.
///
/// If this context is the root the label should return a phrase like
/// "a List" in
///
/// ```
/// Expected: a List that:
/// ```
///
/// If this context is nested under another context the lable should return a
/// phrase like "completes to a value" in
///
///
/// ```
/// Expected: a Future<int> that:
/// completes to a value that:
/// ```
///
/// In cases where a nested context does not have any expectations checked on
/// it, the "that:" will be will be omitted.
final Iterable<String> Function() _labelCallback;
/// A callback that takes a collapsed single-line predicate noun phrase from a
/// nested subject and expands it with the property or condition contributed
/// by this subject level.
///
/// Should return a combined single-line noun phrase, or `null` if this
/// level cannot be collapsed (for example if a key or property is too large).
///
/// When `null`, this context level cannot collapse nested predicate nouns via
/// custom formatting (though if `_parent == null`, the root context can still
/// attempt a fallback formatting with its label).
final String? Function(String predicateNoun)? _addPredicate;
static Iterable<String> _emptyLabel() => const [];
/// Create a context appropriate for a subject which is not nested under any
/// other subject.
_TestContext._root({
required _Optional<T> value,
required void Function(CheckFailure) fail,
required bool allowAsync,
required bool allowUnawaited,
String? label,
}) : _value = value,
_labelCallback = (() => [label ?? '']),
_addPredicate = null,
_fail = fail,
_allowAsync = allowAsync,
_allowUnawaited = allowUnawaited,
_parent = null,
_clauses = [],
_aliases = [];
_TestContext._alias(_TestContext original, this._value)
: _parent = original,
_clauses = original._clauses,
_aliases = original._aliases,
_fail = original._fail,
_allowAsync = original._allowAsync,
_allowUnawaited = original._allowUnawaited,
_addPredicate = null,
// Never read from an aliased context because they are never present in
// `_clauses`.
_labelCallback = _emptyLabel;
/// Create a context nested under [parent].
///
/// The [_labelCallback] callback should not return an empty iterable.
_TestContext._child(
this._value,
this._labelCallback,
this._addPredicate,
_TestContext<dynamic> parent,
) : _parent = parent,
_fail = parent._fail,
_allowAsync = parent._allowAsync,
_allowUnawaited = parent._allowUnawaited,
_clauses = [],
_aliases = [];
@override
void expect(
Iterable<String> Function() clause,
Rejection? Function(T) predicate, {
String? Function()? predicateNoun,
}) {
_clauses.add(_ExpectationClause(clause, predicateNoun));
final rejection = _value.apply(
(actual) => predicate(actual)?._fillActual(actual),
);
if (rejection != null) {
_fail(_failure(rejection));
}
}
@override
Future<void> expectAsync(
Iterable<String> Function() clause,
FutureOr<Rejection?> Function(T) predicate, {
String? Function()? predicateNoun,
}) {
if (!_allowAsync) throw AsyncConditionDisallowed._('Async');
return _expectAsync(clause, predicate, predicateNoun: predicateNoun);
}
Future<void> _expectAsync(
Iterable<String> Function() clause,
FutureOr<Rejection?> Function(T) predicate, {
String? Function()? predicateNoun,
}) async {
_clauses.add(_ExpectationClause(clause, predicateNoun));
final outstandingWork = TestHandle.current.markPending();
try {
final rejection = await _value.apply(
(actual) async => (await predicate(actual))?._fillActual(actual),
);
if (rejection == null) return;
_fail(_failure(rejection));
} finally {
outstandingWork.complete();
}
}
@override
void expectUnawaited(
Iterable<String> Function() clause,
void Function(T actual, void Function(Rejection) reject) predicate, {
String? Function()? predicateNoun,
}) {
if (!_allowUnawaited) throw AsyncConditionDisallowed._('Unawaited');
_clauses.add(_ExpectationClause(clause, predicateNoun));
_value.apply((actual) {
predicate(actual, (r) => _fail(_failure(r._fillActual(actual))));
});
}
@override
Subject<R> nest<R>(
Iterable<String> Function() label,
Extracted<R> Function(T) extract, {
Condition<R>? nestedCondition,
bool atSameLevel = false,
String? Function(String predicateNoun)? addPredicate,
}) {
final result = _value.map((actual) => extract(actual)._fillActual(actual));
final rejection = result._rejection;
if (rejection != null) {
_clauses.add(_ExpectationClause(label));
_fail(_failure(rejection));
}
final value = result._value ?? _Absent<R>();
final _TestContext<R> context;
if (atSameLevel) {
context = _TestContext._alias(this, value);
_aliases.add(context);
if (label().isNotEmpty) {
_clauses.add(_GuardClause(label));
}
} else {
context = _TestContext._child(value, label, addPredicate, this);
_clauses.add(context);
}
final subject = Subject<R>._(context);
nestedCondition?.applySync(subject);
return subject;
}
@override
Future<Subject<R>> nestAsync<R>(
Iterable<String> Function() label,
FutureOr<Extracted<R>> Function(T) extract,
Condition<R>? nestedCondition, {
String? Function(String predicateNoun)? addPredicate,
}) {
if (!_allowAsync) throw AsyncConditionDisallowed._('Async');
return _nestAsync(
label,
extract,
nestedCondition,
addPredicate: addPredicate,
);
}
Future<Subject<R>> _nestAsync<R>(
Iterable<String> Function() label,
FutureOr<Extracted<R>> Function(T) extract,
Condition<R>? nestedCondition, {
String? Function(String predicateNoun)? addPredicate,
}) async {
final outstandingWork = TestHandle.current.markPending();
try {
final result = await _value.mapAsync(
(actual) async => (await extract(actual))._fillActual(actual),
);
final rejection = result._rejection;
if (rejection != null) {
_clauses.add(_ExpectationClause(label));
_fail(_failure(rejection));
}
final value = result._value ?? _Absent<R>();
final context = _TestContext<R>._child(value, label, addPredicate, this);
_clauses.add(context);
final subject = Subject<R>._(context);
await nestedCondition?.apply(subject);
return subject;
} finally {
outstandingWork.complete();
}
}
CheckFailure _failure(Rejection rejection) =>
CheckFailure(rejection, () => _root.detail(this, rejection));
_TestContext get _root {
_TestContext<dynamic> current = this;
while (current._parent != null) {
current = current._parent;
}
return current;
}
/// Whether this context nests under [clause] (in other words, whether
/// [clause] is this context itself or one of its ancestors).
///
/// The result is used to identify which clauses are on the path to the
/// failing expectation, and should therefore be formatted with the rejection
/// details.
bool _nestsUnder(_ClauseDescription clause) {
if (identical(clause, this)) return true;
if (clause.isLeaf) return false;
var current = _parent;
while (current != null) {
if (identical(current, clause)) return true;
current = current._parent;
}
return false;
}
@override
bool get isLeaf => false;
@override
FailureDetail detail(_TestContext failingContext, Rejection? rejection) {
final thisContextFailed =
identical(failingContext, this) || _aliases.contains(failingContext);
return _collapsedDetails(failingContext, rejection) ??
_fullDetails(
failingContext,
rejection,
thisContextFailed: thisContextFailed,
);
}
_ClauseDescription? get _singleClause =>
_clauses.where((c) => c is! _GuardClause).singleOrNull;
/// A collapsed single-line [FailureDetail], or `null` if this context cannot
/// be collapsed.
///
/// If this context has exactly one clause, and that child clause produces a
/// collapsed representation (`childCollapsed`), this method attempts to
/// expand that child representation into a single-line failure detail for
/// this context level.
///
/// Returns `null` if there is not exactly one clause, if the child clause did
/// not collapse, or if expanding the child representation at this level fails
/// (e.g., if `_addPredicate` returns `null` or the resulting root string
/// exceeds length limits).
FailureDetail? _collapsedDetails(
_TestContext failingContext,
Rejection? rejection,
) {
if (rejection == null) return null;
final clause = _singleClause;
if (clause == null) return null;
final details = clause.detail(failingContext, rejection);
final childCollapsed = details.collapsedDetails;
if (childCollapsed == null) return null;
assert(clause.isLeaf ? details.depth == -1 : details.depth == 0);
final (childExpected, childActual) = childCollapsed;
(String, String)? expandedCollapsed;
if (_addPredicate != null) {
final exp = _addPredicate(childExpected);
final act = _addPredicate(childActual);
if (exp != null && act != null) expandedCollapsed = (exp, act);
} else if (_parent == null) {
if (clause.isLeaf) {
expandedCollapsed = childCollapsed;
} else {
final exp = _collapseWithRootLabel(childExpected);
final act = _collapseWithRootLabel(childActual);
if (exp != null && act != null) expandedCollapsed = (exp, act);
}
}
if (expandedCollapsed == null) return null;
return FailureDetail(
[expandedCollapsed.$1],
-1,
0,
collapsedDetails: expandedCollapsed,
);
}
String? _collapseWithRootLabel(String child) {
if (_parent != null) return null;
final rootLabel = _labelCallback().singleOrNull;
if (rootLabel == null) return null;
final combined = rootLabel.isEmpty ? child : '$rootLabel that $child';
return combined.length <= 80 ? combined : null;
}
@override
String? get collapsedExpected {
final child = _singleClause?.collapsedExpected;
if (child == null) return null;
return _addPredicate?.call(child);
}
/// The [FailureDetail] for this context when the overall expectation cannot
/// collapse to a single line.
///
/// Iterates through clauses under this context to construct `expected`,
/// tracking the depth to the failing clause and the overlap of succeeded
/// clauses. Passing non-leaf clauses collapse to a single line using their
/// [collapsedExpected] where available.
///
/// Incorporates partial collapsing into `actual`:
/// - If this context is the failing context, uses [_addPredicate] to collapse
/// the rejected actual value when possible.
/// - At the root context, if the failing clause is the only clause in
/// `actual` and produces a single-line actual description within length
/// limits, combines the root label and child description on a single line
/// and adjusts depth to 0.
/// - Otherwise, formats `actual` hierarchically with indentation, retaining
/// any collapsed child property descriptions.
FailureDetail _fullDetails(
_TestContext failingContext,
Rejection? rejection, {
required bool thisContextFailed,
}) {
var foundOverlap = thisContextFailed ? 0 : -1;
var foundDepth = thisContextFailed ? 0 : -1;
if (_clauses.isEmpty) {
return FailureDetail(_labelCallback(), foundOverlap, foundDepth);
}
final expected = [...postfixLast(' that:', _labelCallback())];
final actual = [...expected];
final labelLines = expected.length;
var canCollapse = true;
var didCollapse = false;
var successfulOverlap = 0;
for (var clause in _clauses) {
final isFailingClause = thisContextFailed
? identical(clause, _clauses.last)
: failingContext._nestsUnder(clause);
final clauseRejection = isFailingClause ? rejection : null;
final details = clause.detail(failingContext, clauseRejection);
final Iterable<String> clauseExpected;
var didCollapseRootActual = false;
if (isFailingClause) {
clauseExpected = details.expected;
final childActual = !clause.isLeaf && details.collapsedDetails != null
? details.collapsedDetails!.$2
: (details.hasCustomActual ? details.actual.singleOrNull : null);
final collapsedRootActual =
childActual != null && actual.length == labelLines
? _collapseWithRootLabel(childActual)
: null;
if (collapsedRootActual != null) {
actual
..clear()
..add(collapsedRootActual);
didCollapse = true;
didCollapseRootActual = true;
} else if (childActual != null) {
actual.addAll(indent([childActual]));
didCollapse = true;
} else if (details.hasCustomActual) {
actual.addAll(indent(details.actual));
didCollapse = true;
} else {
canCollapse = false;
}
} else {
final collapsed = rejection != null && !clause.isLeaf
? clause.collapsedExpected
: null;
clauseExpected = collapsed != null ? [collapsed] : details.expected;
actual.addAll(indent(clauseExpected));
if (collapsed != null) didCollapse = true;
}
expected.addAll(indent(clauseExpected));
if (details.depth >= 0) {
assert(foundDepth == -1);
assert(foundOverlap == -1);
foundDepth = didCollapseRootActual ? details.depth : details.depth + 1;
final overlapIncrement = details.collapsedDetails != null ? 1 : 0;
foundOverlap =
labelLines +
successfulOverlap +
details._actualOverlap +
overlapIncrement;
} else if (foundDepth == -1) {
successfulOverlap += clauseExpected.length;
}
}
assert(rejection == null || foundDepth >= 0);
List<String>? actualResult;
if (canCollapse && didCollapse) {
actualResult = actual;
} else if (thisContextFailed && _addPredicate != null) {
if (rejection?.actual.singleOrNull case final actualValue?) {
if (_addPredicate(actualValue) case final collapsed?) {
actualResult = [collapsed];
}
}
}
return FailureDetail(
expected,
foundOverlap,
foundDepth,
actual: actualResult,
);
}
}
/// A context which never runs expectations and can never fail.
final class _SkippedContext<T> implements Context<T> {
@override
void expect(
Iterable<String> Function() clause,
Rejection? Function(T) predicate, {
String? Function()? predicateNoun,
}) {
// no-op
}
@override
Future<void> expectAsync(
Iterable<String> Function() clause,
FutureOr<Rejection?> Function(T) predicate, {
String? Function()? predicateNoun,
}) async {
// no-op
}
@override
void expectUnawaited(
Iterable<String> Function() clause,
void Function(T actual, void Function(Rejection) reject) predicate, {
String? Function()? predicateNoun,
}) {
// no-op
}
@override
Subject<R> nest<R>(
Iterable<String> Function() label,
Extracted<R> Function(T p1) extract, {
Condition<R>? nestedCondition,
bool atSameLevel = false,
String? Function(String predicateNoun)? addPredicate,
}) {
final subject = Subject<R>._(_SkippedContext());
nestedCondition?.applySync(subject);
return subject;
}
@override
Future<Subject<R>> nestAsync<R>(
Iterable<String> Function() label,
FutureOr<Extracted<R>> Function(T p1) extract,
Condition<R>? nestedCondition, {
String? Function(String predicateNoun)? addPredicate,
}) async {
final subject = Subject<R>._(_SkippedContext());
await nestedCondition?.apply(subject);
return subject;
}
}
final class _ReplayContext<T> implements Context<T> {
final _interactions = <FutureOr<void> Function(Context<T>)>[];
final Condition<T>? _nestedCondition;
_ReplayContext([this._nestedCondition]);
FutureOr<CheckFailure?> softCheck(T value, {required bool allowAsync}) {
CheckFailure? failure;
final subject = Subject<T>._(
_TestContext._root(
value: _Present(value),
fail: (f) {
failure ??= f;
},
allowAsync: allowAsync,
allowUnawaited: false,
),
);
final result = apply(subject);
if (result is Future) {
return result.then((_) => failure);
}
return failure;
}
FutureOr<void> apply(Subject<T> subject) {
final result = _nestedCondition?.apply(subject);
if (result is Future) {
return result.then((_) => _runInteractions(subject));
}
return _runInteractions(subject);
}
FutureOr<void> _runInteractions(Subject<T> subject) {
for (var i = 0; i < _interactions.length; i++) {
final interaction = _interactions[i];
final result = interaction(subject.context);
if (result is Future) {
return result.then((_) => _runInteractionsFrom(subject, i + 1));
}
}
}
Future<void> _runInteractionsFrom(Subject<T> subject, int startIndex) async {
for (var i = startIndex; i < _interactions.length; i++) {
await _interactions[i](subject.context);
}
}
FutureOr<Iterable<String>> describe({required bool allowAsync}) {
final context = _TestContext<T>._root(
value: _Absent(),
fail: (_) {
throw UnimplementedError();
},
allowAsync: allowAsync,
allowUnawaited: true,
);
final result = apply(Subject._(context));
if (result is Future) {
return result.then((_) => context.detail(context, null).expected.skip(1));
}
return context.detail(context, null).expected.skip(1);
}
@override
void expect(
Iterable<String> Function() clause,
Rejection? Function(T) predicate, {
String? Function()? predicateNoun,
}) {
_interactions.add((c) {
c.expect(clause, predicate, predicateNoun: predicateNoun);
});
}
@override
Future<void> expectAsync(
Iterable<String> Function() clause,
FutureOr<Rejection?> Function(T) predicate, {
String? Function()? predicateNoun,
}) {
_interactions.add(
(c) => c.expectAsync(clause, predicate, predicateNoun: predicateNoun),
);
return Future.syncValue(null);
}
@override
void expectUnawaited(
Iterable<String> Function() clause,
void Function(T, void Function(Rejection)) predicate, {
String? Function()? predicateNoun,
}) {
_interactions.add((c) {
c.expectUnawaited(clause, predicate, predicateNoun: predicateNoun);
});
}
@override
Subject<R> nest<R>(
Iterable<String> Function() label,
Extracted<R> Function(T) extract, {
Condition<R>? nestedCondition,
bool atSameLevel = false,
String? Function(String predicateNoun)? addPredicate,
}) {
final nestedContext = _ReplayContext<R>(nestedCondition);
_interactions.add((c) {
final subject = c.nest(
label,
extract,
atSameLevel: atSameLevel,
addPredicate: addPredicate,
);
return nestedContext.apply(subject);
});
return Subject._(nestedContext);
}
@override
Future<Subject<R>> nestAsync<R>(
Iterable<String> Function() label,
FutureOr<Extracted<R>> Function(T) extract,
Condition<R>? nestedCondition, {
String? Function(String predicateNoun)? addPredicate,
}) {
final nestedContext = _ReplayContext<R>(nestedCondition);
_interactions.add((c) async {
final nested = await c.nestAsync(
label,
extract,
null,
addPredicate: addPredicate,
);
await nestedContext.apply(nested);
});
return Future.syncValue(Subject._(nestedContext));
}
}
abstract interface class _ClauseDescription {
FailureDetail detail(_TestContext failingContext, Rejection? rejection);
String? get collapsedExpected;
bool get isLeaf;
}
class _ExpectationClause implements _ClauseDescription {
final Iterable<String> Function() _expected;
final String? Function()? _predicateNoun;
_ExpectationClause(this._expected, [this._predicateNoun]);
@override
String? get collapsedExpected => _predicateNoun?.call();
@override
FailureDetail detail(_TestContext failingContext, Rejection? rejection) {
(String, String)? collapsedDetails;
if (_predicateNoun?.call() case final collapsedExpected?) {
if (rejection?.actual.singleOrNull case final collapsedActual?) {
collapsedDetails = (collapsedExpected, collapsedActual);
}
}
return FailureDetail(
_expected(),
-1,
-1,
collapsedDetails: collapsedDetails,
);
}
@override
bool get isLeaf => true;
}
/// An expectation clause created for a non-empty label when nesting
/// `atSameLevel` (such as `isNotNull()` or `isA<T>()`).
///
/// When value extraction succeeds, the clause represents a satisfied
/// precondition. Succeeded guard clauses are included in uncollapsed
/// multi-line failure descriptions, but do not disqualify a context from
/// collapsing when only a single expectation is otherwise checked on the
/// subject.
final class _GuardClause extends _ExpectationClause {
_GuardClause(super.expected);
}
/// The result of an expectation that failed for a subject.
final class CheckFailure {
/// The specific rejected value within the overall subject that caused the
/// failure.
///
/// The [Rejection.actual] may be a property derived from the value at the
/// root of the subject, for instance a field or an element in a collection.
final Rejection rejection;
/// The context within the overall subject where an expectation resulted in
/// the [rejection].
late final FailureDetail detail = _readDetail();
final FailureDetail Function() _readDetail;
CheckFailure(this.rejection, this._readDetail);
}
/// The context for a failed expectation.
///
/// A subject may have some number of succeeding expectations, and the failure may
/// be for an expectation against a property derived from the value at the root
/// of the subject. For example, in `check([]).length.identicalTo(1)` the
/// specific value that gets rejected is `0` from the length of the list, and
/// the subject that sees the rejection is nested with the label "has length".
final class FailureDetail {
/// A description of all the conditions the subject was expected to satisfy.
///
/// Each subject has a label. At the root the label is typically "a
/// &lt;Type&gt;" and nested subjects get a label based on the condition
/// which extracted a property for further checks. Each level of nesting is
/// described as "&lt;label&gt; that:" followed by an indented list of the
/// expectations for that property.
///
/// For example:
///
/// ```
/// a List<int> that:
/// has first element that:
/// is greater than <10>
/// is less than <100>
/// ```
final Iterable<String> expected;
/// A description of the conditions the checked value satisfied.
///
/// When a custom or collapsed actual description is available, returns it.
/// Otherwise, matches the format of [expected], cut off after the label for
/// the subject that had a failing expectation. For example, if a custom
/// nested subject without an `addPredicate` callback fails:
///
/// ```
/// a CustomType that:
/// has someDerivedValue that:
/// ```
///
/// If the subject with a failing expectation is the root without custom
/// actual, returns an empty list. Instead the "Actual: " value from the
/// rejection can be used without indentation.
Iterable<String> get actual =>
_actual ??
(_actualOverlap > 0 ? expected.take(_actualOverlap + 1) : const []);
/// The number of lines from [expected] which describe conditions that were
/// successful.
///
/// A failed expectation on a derived property may have some number of
/// expectations that were checked and satisfied starting from the root
/// subject. This field indicates how many lines of expectations were
/// successful.
final int _actualOverlap;
/// The number of times the failing subject was nested from the root subject.
///
/// Indicates how far the "Actual: " and "Which: " lines from the [Rejection]
/// should be indented so that they are at the same level of indentation as
/// the label for the subject where the expectation failed.
///
/// For example, when multiple properties are checked on a `List`:
///
/// ```
/// a List<int> that:
/// has length: <1>
/// has first element: <2>
/// ```
///
/// If the check on `has first element` fails, the [depth] will be `1` to
/// indicate that the failure occurred checking an expectation at that
/// indentation level.
final int depth;
/// The single-line representation of the expectation failure as a pair of
/// `(expected, actual)` strings, or `null` if the failure was not collapsed.
///
/// When non-null, the failure message can be displayed compactly on single
/// `Expected:` and `Actual:` lines without multi-line nesting structure.
final (String, String)? collapsedDetails;
final Iterable<String>? _actual;
/// Whether this detail provides an explicit, custom [actual] description
/// (such as from a partially collapsed child condition) rather than relying
/// on the default overlap prefix derived from [expected].
bool get hasCustomActual => _actual != null;
FailureDetail(
this.expected,
this._actualOverlap,
this.depth, {
this.collapsedDetails,
Iterable<String>? actual,
}) : _actual = actual;
}
/// A description of a value that failed an expectation.
final class Rejection {
/// A description of the actual value as it relates to the expectation.
///
/// This may use [literal] to show a String representation of the value, or it
/// may be a description of a specific aspect of the value. For instance an
/// expectation that a Future completes to a value may describe the actual as
/// "A Future that completes to an error".
///
/// When a value is rejected with no [actual] argument, it will be filled in
/// with the [literal] representation of the value.
///
/// Lines should be split to separate elements, and individual strings should
/// not contain newlines.
///
/// This is printed following an "Actual: " label in the output of a failure
/// message. All lines in the message will be indented to the level of the
/// expectation in the description, and printed following the descriptions of
/// any expectations that have already passed.
final Iterable<String> actual;
/// A description of the way that [actual] failed to meet the expectation.
///
/// An expectation can provide extra detail, or focus attention on a specific
/// part of the value. For instance when comparing multiple elements in a
/// collection, the rejection may describe that the value "has an unequal
/// value at index 3".
///
/// Lines should be separate values in the iterable, if any element contains a
/// newline it may cause problems with indentation in the output.
///
/// When provided, this is printed following a "Which: " label at the end of
/// the output for the failure message.
final Iterable<String>? which;
Rejection _fillActual(Object? value) => actual.isNotEmpty
? this
: Rejection(actual: literal(value), which: which);
Rejection({this.actual = const [], this.which});
}
class AsyncConditionDisallowed implements Exception {
final String flavor;
AsyncConditionDisallowed._(this.flavor);
@override
String toString() => '$flavor expectations cannot be used on this subject';
}