Skip to content

Commit

Permalink
Add containsMatchingInOrder containsEqualInOrder (#2284)
Browse files Browse the repository at this point in the history
The joined behavior in `containsInOrder` has some usability issues:
- It mimics the arguments for `deepEquals`, but it doesn't have the same
  behavior for collection typed elements. Checking that a nested
  collection is contained in order requires a `Condition` callback that
  uses `.deepEquals` explicitly.
- The `Object?` signature throws away inference on the `Condition`
  callback arguments. With a method that supports only conditions the
  argument type can be tightened and allow inference.


Deprecate the old `containsInOrder` and plan to remove it before stable.
This is a bit more restrictive, but it's not too noisy to fit a few
`(it) => it.equals(foo)` in a collection that needs mixed behavior and
the collection of two methods is less confusing to document than the
joined behavior.

Lean on the "Matches" verb for cases that check a `Condition` callback
and rename `pairwiseComparesTo` as `pairwiseMatches`.

Fix a type check when pretty printing `Condition` callbacks. Match
more than `Condition<dynamic>` by checking `Condition<Never>`.
  • Loading branch information
natebosch authored Oct 2, 2024
1 parent d872cf8 commit df3e2f1
Show file tree
Hide file tree
Showing 5 changed files with 165 additions and 7 deletions.
3 changes: 3 additions & 0 deletions pkgs/checks/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
for equality. This maintains the path into a nested collection for typical
cases of checking for equality against a purely value collection.
- Always wrap Condition descriptions in angle brackets.
- Add `containsMatchingInOrder` and `containsEqualInOrder` to replace the
combined functionality in `containsInOrder`.
- Replace `pairwiseComparesTo` with `pairwiseMatches`.

## 0.3.0

Expand Down
7 changes: 6 additions & 1 deletion pkgs/checks/doc/migrating_from_matcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,14 @@ check(because: 'some explanation', actual).expectation();
- `containsPair(key, value)` -> Use `Subject<Map>[key].equals(value)`
- `hasLength(expected)` -> `length.equals(expected)`
- `isNot(Matcher)` -> `not(conditionCallback)`
- `pairwiseCompare` -> `pairwiseComparesTo`
- `pairwiseCompare` -> `pairwiseMatches`
- `same` -> `identicalTo`
- `stringContainsInOrder` -> `Subject<String>.containsInOrder`
- `containsAllInOrder(iterable)` ->
`Subject<Iterable>.containsMatchingInOrder(iterable)` to compare with
conditions other than equals,
`Subject<Iterable>.containsEqualInOrder(iterable)` to compare each index
with the equality operator (`==`).

### Members from `package:test/expect.dart` without a direct replacement

Expand Down
2 changes: 1 addition & 1 deletion pkgs/checks/lib/src/describe.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Iterable<String> _prettyPrint(
.map((line) => line.replaceAll("'", r"\'"))
.toList();
return prefixFirst("'", postfixLast("'", escaped));
} else if (object is Condition) {
} else if (object is Condition<Never>) {
return ['<A value that:', ...postfixLast('>', describe(object))];
} else {
final value = const LineSplitter().convert(object.toString());
Expand Down
87 changes: 87 additions & 0 deletions pkgs/checks/lib/src/extensions/iterable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ extension IterableChecks<T> on Subject<Iterable<T>> {
/// check([1, 0, 2, 0, 3])
/// .containsInOrder([1, (Subject<int> v) => v.isGreaterThan(1), 3]);
/// ```
@Deprecated('Use `containsEqualInOrder` for expectations with values compared'
' with `==` or `containsMatchingInOrder` for other expectations')
void containsInOrder(Iterable<Object?> elements) {
context.expect(() => prefixFirst('contains, in order: ', literal(elements)),
(actual) {
Expand All @@ -115,6 +117,74 @@ extension IterableChecks<T> on Subject<Iterable<T>> {
});
}

/// Expects that the iterable contains a value matching each condition in
/// [conditions] in the given order, with any extra elements between them.
///
/// For example, the following will succeed:
///
/// ```dart
/// check([1, 10, 2, 10, 3]).containsMatchingInOrder([
/// (it) => it.isLessThan(2),
/// (it) => it.isLessThan(3),
/// (it) => it.isLessThan(4),
/// ]);
/// ```
void containsMatchingInOrder(Iterable<Condition<T>> conditions) {
context
.expect(() => prefixFirst('contains, in order: ', literal(conditions)),
(actual) {
final expected = conditions.toList();
if (expected.isEmpty) {
throw ArgumentError('expected may not be empty');
}
var expectedIndex = 0;
for (final element in actual) {
final currentExpected = expected[expectedIndex];
final matches = softCheck(element, currentExpected) == null;
if (matches && ++expectedIndex >= expected.length) return null;
}
return Rejection(which: [
...prefixFirst(
'did not have an element matching the expectation at index '
'$expectedIndex ',
literal(expected[expectedIndex])),
]);
});
}

/// Expects that the iterable contains a value equals to each expected value
/// from [elements] in the given order, with any extra elements between
/// them.
///
/// For example, the following will succeed:
///
/// ```dart
/// check([1, 0, 2, 0, 3]).containsInOrder([1, 2, 3]);
/// ```
///
/// Values, will be compared with the equality operator.
void containsEqualInOrder(Iterable<T> elements) {
context.expect(() => prefixFirst('contains, in order: ', literal(elements)),
(actual) {
final expected = elements.toList();
if (expected.isEmpty) {
throw ArgumentError('expected may not be empty');
}
var expectedIndex = 0;
for (final element in actual) {
final currentExpected = expected[expectedIndex];
final matches = currentExpected == element;
if (matches && ++expectedIndex >= expected.length) return null;
}
return Rejection(which: [
...prefixFirst(
'did not have an element equal to the expectation at index '
'$expectedIndex ',
literal(expected[expectedIndex])),
]);
});
}

/// Expects that the iterable contains at least on element such that
/// [elementCondition] is satisfied.
void any(Condition<T> elementCondition) {
Expand Down Expand Up @@ -250,7 +320,24 @@ extension IterableChecks<T> on Subject<Iterable<T>> {
/// [description] is used in the Expected clause. It should be a predicate
/// without the object, for example with the description 'is less than' the
/// full expectation will be: "pairwise is less than $expected"
@Deprecated('Use `pairwiseMatches`')
void pairwiseComparesTo<S>(List<S> expected,
Condition<T> Function(S) elementCondition, String description) =>
pairwiseMatches(expected, elementCondition, description);

/// Expects that the iterable contains elements that correspond by the
/// [elementCondition] exactly to each element in [expected].
///
/// Fails if the iterable has a different length than [expected].
///
/// For each element in the iterable, calls [elementCondition] with the
/// corresponding element from [expected] to get the specific condition for
/// that index.
///
/// [description] is used in the Expected clause. It should be a predicate
/// without the object, for example with the description 'is less than' the
/// full expectation will be: "pairwise is less than $expected"
void pairwiseMatches<S>(List<S> expected,
Condition<T> Function(S) elementCondition, String description) {
context.expect(() {
return prefixFirst('pairwise $description ', literal(expected));
Expand Down
73 changes: 68 additions & 5 deletions pkgs/checks/test/extensions/iterable_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,69 @@ void main() {
]);
});
});

group('containsMatchingInOrder', () {
test('succeeds for happy case', () {
check([0, 1, 0, 2, 0, 3]).containsMatchingInOrder([
(it) => it.isLessThan(2),
(it) => it.isLessThan(3),
(it) => it.isLessThan(4),
]);
});
test('fails for not found elements', () async {
check([0]).isRejectedBy(
(it) => it.containsMatchingInOrder([(it) => it.isGreaterThan(0)]),
which: [
'did not have an element matching the expectation at index 0 '
'<A value that:',
' is greater than <0>>'
]);
});
test('can be described', () {
check((Subject<Iterable<int>> it) => it.containsMatchingInOrder([
(it) => it.isLessThan(2),
(it) => it.isLessThan(3),
(it) => it.isLessThan(4),
])).description.deepEquals([
' contains, in order: [<A value that:',
' is less than <2>>,',
' <A value that:',
' is less than <3>>,',
' <A value that:',
' is less than <4>>]',
]);
check((Subject<Iterable<int>> it) => it.containsMatchingInOrder(
[(it) => it.equals(1), (it) => it.equals(2)]))
.description
.deepEquals([
' contains, in order: [<A value that:',
' equals <1>>,',
' <A value that:',
' equals <2>>]'
]);
});
});

group('containsEqualInOrder', () {
test('succeeds for happy case', () {
check([0, 1, 0, 2, 0, 3]).containsEqualInOrder([1, 2, 3]);
});
test('fails for not found elements', () async {
check([0]).isRejectedBy((it) => it.containsEqualInOrder([1]), which: [
'did not have an element equal to the expectation at index 0 <1>'
]);
});
test('can be described', () {
check((Subject<Iterable<int>> it) => it.containsEqualInOrder([1, 2, 3]))
.description
.deepEquals([' contains, in order: [1, 2, 3]']);
check((Subject<Iterable<int>> it) => it.containsEqualInOrder([1, 2]))
.description
.deepEquals([
' contains, in order: [1, 2]',
]);
});
});
group('every', () {
test('succeeds for the happy path', () {
check(_testIterable).every((it) => it.isGreaterOrEqual(-1));
Expand Down Expand Up @@ -178,14 +241,14 @@ void main() {
});
});

group('pairwiseComparesTo', () {
group('pairwiseMatches', () {
test('succeeds for the happy path', () {
check(_testIterable).pairwiseComparesTo([1, 2],
check(_testIterable).pairwiseMatches([1, 2],
(expected) => (it) => it.isLessThan(expected), 'is less than');
});
test('fails for mismatched element', () async {
check(_testIterable).isRejectedBy(
(it) => it.pairwiseComparesTo([1, 1],
(it) => it.pairwiseMatches([1, 1],
(expected) => (it) => it.isLessThan(expected), 'is less than'),
which: [
'does not have an element at index 1 that:',
Expand All @@ -196,15 +259,15 @@ void main() {
});
test('fails for too few elements', () {
check(_testIterable).isRejectedBy(
(it) => it.pairwiseComparesTo([1, 2, 3],
(it) => it.pairwiseMatches([1, 2, 3],
(expected) => (it) => it.isLessThan(expected), 'is less than'),
which: [
'has too few elements, there is no element to match at index 2'
]);
});
test('fails for too many elements', () {
check(_testIterable).isRejectedBy(
(it) => it.pairwiseComparesTo([1],
(it) => it.pairwiseMatches([1],
(expected) => (it) => it.isLessThan(expected), 'is less than'),
which: ['has too many elements, expected exactly 1']);
});
Expand Down

0 comments on commit df3e2f1

Please sign in to comment.