Restore Condition as a class - #2697
Conversation
Closes #2694 Reintroduce `Condition<T>` as a class in place of callback typedefs (`void Function(Subject<T>)` / `Future<void> Function(Subject<T>)`). Previously, the `Condition` class was dropped in favor of callback functions, in part to avoid `it()` appearing like a language feature. However, using callback functions introduced type inference limitations. - Callbacks required declaring explicit parameters (e.g., `(it) => ...`), introducing unnecessary boilerplate across expectation calls. - When explicit types are required it takes a full `Subject<SomeType>` as the argument type annotation, mixing the details of the implementation with the goal of passing a type argument. - When type inference failed (such as in collection matching or heterogeneous checks), callbacks resulted in cryptic runtime errors regarding missing extension methods on `Subject` with no static assistance. Reintroducing `Condition<T>` with `Condition.it<T>()` static helper. - Dot shorthands provide a terse, readable syntax (`.it()..isGreaterThan(0)`) without polluting the top-level namespace. - When inference fails `Condition.it<SomeType>()` is more explicit and readable than `(Subject<SomeType> it) =>`. - Consolidating `softCheck`, `softCheckSync`, `describe`, and `describeSync` as instance methods on `Condition<T>` (and eliminating standalone top-level functions `softCheck`, `softCheckAsync`, `describe`, `describeAsync`) improves overall API discoverability and ergonomics, despite being a breaking change. Example Migration: Before: ```dart check(numbers).any((it) => it.isGreaterThan(5)); check(map).containsKeyThat((key) => key ..startsWith('a') ..has((s) => s.length, 'length').isGreaterThan(2)); ``` After: ```dart check(numbers).any(.it()..isGreaterThan(5)); check(map).containsKeyThat(.it() ..startsWith('a') ..has((s) => s.length, 'length').isGreaterThan(2)); ```
|
You can see the impact on test code in the diff here - somewhat eaiser if you start at Probably even easier to see the impact on tests in
This PR sits on top of #2683 because it's a more drastic change when there are more extensions using this syntax. We can land this without the other easily enough if we decide to. If we decide not to land this one I think I'd be less inclined to land the other. |
|
Probably could do something monadic like: check(numbers).any.isGreaterThan(5);but it's more complexity, and monads may not always nest nicely. I like this approach. It generalizes abstracting over operations on a I'd love to take it a bit further ... but that's not realistic. check(numbers).any(.it()..isGreaterThan(5));If we could just make check(numbers).any.isGreaterThan(5);That sadly has serious timing issues, the |
| } | ||
| }, | ||
| errorCondition, | ||
| ); |
There was a problem hiding this comment.
(I had an idea, so I'll put it here 😁:
What if there was Future<Subject<E>> ifThrows<E extends Object>([Condition<E>? condition]) which:
- If the future throws an
E, it's checked againsts condition, and if successful, returned. - If it throws a non-
E, throws anEnot satisfying condition, or it completes with a value, the returned subject is a "skipped" subject that can't fail.
So more of a guard than a check.
And similar for ifCompletes
Then you could do
check(parseFuture)
..ifCompletes(.it.equals(42))
..ifThrows(.it.isA<FormatException>());Have to make sure all error futures are properly handled on all branches.)
| /// | ||
| /// Any expectations may be used within an `AsyncCondition` callback. | ||
| typedef AsyncCondition<T> = FutureOr<void> Function(Subject<T>); | ||
|
|
There was a problem hiding this comment.
(Thanks. I hate typedefs in APIs. I look at a signature in an IDE and it just says AsyncCondition<T>, and then I still don't know what type I have to write. I need to go look at the declaration anyway.)
| /// 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>() => ._(); |
There was a problem hiding this comment.
(It's really annoying that this has to be a method, just because it has to be generic. We should have generic getters!)
| await condition(subject); | ||
| return failure; | ||
| } | ||
| /// Run these exepctations against [subject]. |
| /// otherwise it will complete to the [CheckFailure] for the first expectation | ||
| /// that fails. | ||
| /// | ||
| /// Asynchronous expectations are not allowed. |
There was a problem hiding this comment.
(Is this a failure represented by a check failure or will that throw an exception? In which case the first line is slighlty misleading)
| /// Run these expectations against [subject]. | ||
| /// | ||
| /// Asynchronous expectations are not allowed and will cause a runtime error | ||
| /// if they were used. |
There was a problem hiding this comment.
(Use this paragraph for the other *Sync functions too, it's more explicit than "is not allowed".)
| /// Fails if the future completes to a value. | ||
| /// | ||
| /// Pass [errorCondition] to check expectations on the error thrown by the | ||
| /// future. |
There was a problem hiding this comment.
Is [ErrorCondition] needed.
Is (await subject.throws<E>()).tests equivalent to await subject.throws<E>(it()..tests)?
If you can do both, which one should we recommend?
(The former shoud work with expectSync, the latter ... might?)
| /// future. | ||
| /// | ||
| /// The returned future will complete when the subject future has completed, | ||
| /// and [errorCondition] has optionally been checked. |
There was a problem hiding this comment.
This errorCondition is check-functions called on the Subject<E> that is caught.
I'm not sure it matters whether the condition is used as a real check, or only as a predicate, other than whether the error of
await check(Future.error(StateError('Apricot'))
.throws(.it().message.equals('Bananas'));Is
Did not throw a StateError that has a message which equals "Bananas".
or
Threw a StateError which has a message which does not equal "Bananas".
That is, whether the test is "did it throw E + do checks on E" or "did it throw E which satisfies predicate".
I think it should be the latter.
(Also because the former is equivalent to (await subject.throws<E>()).checks() anyway, so you can get that if you want it.)
Also, some tests promote, like isA.
Someone might question why:
check(future).completes<Object?>(.it().isA<int>())does not return Future<Subject<int>>.
(There are good reasons, but "it's only used as a predicate" is an easier explanation.)
| return ['emits any values then emits a value that:', ...description]; | ||
| } | ||
| return [ | ||
| 'emits any values then emits a value satisfying an asynchronous condition', |
There was a problem hiding this comment.
(Reading this, I didn't read "any values" as allowing no values. The name didn't help me - I have no intuition what "emits through" means.
I guess it's the inclusive version of emitsUntil, and it's on a StreamQueue.
Consider emitsEventual or emitsAny, and just document that it accepts any events until such one.)
| /// | ||
| /// Waits for each condition to be satisfied or rejected before checking the | ||
| /// next. Subsequent conditions will not see any events consumed by earlier | ||
| /// conditions. |
There was a problem hiding this comment.
(Can maybe say something like "one event per condition, in order" early. I had to read to here to figure out whether one event can satsify more than one condition.
Also "consumed" is not a word used earlier.)
| }), | ||
| ); | ||
| final descriptionFuture = conditions | ||
| .map((c) async => await c.describe()) |
There was a problem hiding this comment.
(That's just (c) => c.describe() with more words, and maybe more delays.)
| ); | ||
| final descriptionFuture = conditions | ||
| .map((c) async => await c.describe()) | ||
| .wait; |
There was a problem hiding this comment.
Can .describe() throw?
(If not, is it documented on the function that it must not throw?)
If it can, then this future may complete with an unhandled async error while doing the next statement.
(If it can't, it'd be nice to know.)
| longestAccepted = copy; | ||
| } | ||
| return failure; | ||
| }).wait; |
There was a problem hiding this comment.
If any of the futures in failures can end in an error, which they can if any of the softChecks throw, and those can be synchronous and calls user code so they almost certainly can throw, ... then the failures future may complete with an unhandled asynchronous error while await descriptionFuture runs below.
This is just a test, and you probably know it won't throw, so document that.
| if (failure == null && | ||
| (longestAccepted == null || | ||
| copy.eventsDispatched > longestAccepted!.eventsDispatched)) { | ||
| longestAccepted = copy; |
There was a problem hiding this comment.
Should the shorter of longestAccepted and copy be closed in some way?
| }).wait; | ||
|
|
||
| descriptions.addAll(await descriptionFuture); | ||
| if (longestAccepted != null) { |
There was a problem hiding this comment.
case final accepted? for the promotion.
| /// instances are run against the value in the same position within [actual]. | ||
| /// Condition instances must be a `Condition<Object?>` or | ||
| /// `Condition<dynamic>` and may not use a more specific generic. | ||
| /// Use `Condition.it<Object?>()..isA<Type>()` to check expectations for |
There was a problem hiding this comment.
Should it be .isA<Type>() and not ..isA<Type>() here? (So you can use checks on Types on it.)
| /// ``` | ||
| /// check(something) | ||
| /// ..has((s) => s.foo, 'foo').equals(expectedFoo) | ||
| /// ..has((s) => s.bar, 'bar').which((b) => b |
There was a problem hiding this comment.
Old format.
which(it()..isLessThan(10)..isGreaterThan(0));| /// ..isGreaterThan(0)); | ||
| /// ``` | ||
| void which(Condition<T> condition) => condition(this); | ||
| void which(Condition<T> condition) => condition.applySync(this); |
There was a problem hiding this comment.
This change may make which more important.
It introduces Condition, an application of checks, as a first-class value that you may want to store for reuse.
You can have whole libraries of conditions. (Probably shouldn't, those should be extensions on subjects of their types instead.)
But still people may want to use which more.
Do we need both which and isA?
What if isA was
/// Checks that the valie is an [R] which satisfies [condition].
R isA<R extends T>([Condition<R>? condition]);(Only allowing downcasts to make .isA(.it()...) work better, maybe have castsTo<T> for general casts.
And because also having an operation that only allows downcasts is better than only having one that allows anything without warning that it's unrelated.)
| return context.detail(context).expected.skip(1); | ||
| /// A [Subject] that records the checks that are performed and expose them as an | ||
| /// [Condition]. | ||
| final class ConditionSubject<T> implements Condition<T>, Subject<T> { |
There was a problem hiding this comment.
ConditionSubject has public methods, which means that you can't use those names as the first check on a condition.
check(myFunctionAbstraction).where(.it()..apply().equals(42));where apply is a custom extension on Subject<MyFunctionAbstraction>, won't work,
but if it was .it()..isA<MyFunctionAbstraction>().apply().equals(42), it would because the static type of isA is just a Subject.
Can keep the methods public, but change it() to return Subject<T> instead of ConditionSubject<T>.
(Does the class and methods need to be public?)
lrhn
left a comment
There was a problem hiding this comment.
I like the approach. I tries to sidestep the "monadic subject" (like AsyncSubject) by abstracting over the things you'd do those subjects, and then injecting them into the Subject<Monad<T>>.
That way there doesn't have to be special subjects for each kind of weird context a value can be in. (And it generalizes.)
It shouldn't be more or less powerful than FutureOr<void> Function(Subject<T> subject), but it's probably easier to work with for users. (And may be easier to type-infer.)
Maybe the Condition class, and the it method, should state very directly that you should always us .it().. to ensure the result is a Condition, since the check calls on it return a normal Subject.
Closes #2694
Reintroduce
Condition<T>as a class in place of callback typedefs(
void Function(Subject<T>)/Future<void> Function(Subject<T>)).Previously, the
Conditionclass was dropped in favor of callbackfunctions, in part to avoid
it()appearing like a language feature.However, using callback functions introduced type inference limitations.
unnecessary boilerplate across expectation calls.
Subject<SomeType>as the argument type annotation, mixing the details of the
implementation with the goal of passing a type argument.
heterogeneous checks), callbacks resulted in cryptic runtime errors
regarding missing extension methods on
Subjectwith no staticassistance.
Reintroducing
Condition<T>withCondition.it<T>()static helper.(
.it()..isGreaterThan(0)) without polluting the top-level namespace.Condition.it<SomeType>()is more explicit andreadable than
(Subject<SomeType> it) =>.softCheck,softCheckSync,describe, anddescribeSyncas instance methods onCondition<T>(and eliminatingstandalone top-level functions
softCheck,softCheckAsync,describe,describeAsync) improves overall API discoverability andergonomics, despite being a breaking change.
Example Migration:
Before:
After: