From df3e2f168061a64a6d5b23264ddf9674a6330851 Mon Sep 17 00:00:00 2001 From: Nate Bosch Date: Tue, 1 Oct 2024 17:43:26 -0700 Subject: [PATCH] Add containsMatchingInOrder containsEqualInOrder (#2284) 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` by checking `Condition`. --- pkgs/checks/CHANGELOG.md | 3 + pkgs/checks/doc/migrating_from_matcher.md | 7 +- pkgs/checks/lib/src/describe.dart | 2 +- pkgs/checks/lib/src/extensions/iterable.dart | 87 +++++++++++++++++++ .../checks/test/extensions/iterable_test.dart | 73 ++++++++++++++-- 5 files changed, 165 insertions(+), 7 deletions(-) diff --git a/pkgs/checks/CHANGELOG.md b/pkgs/checks/CHANGELOG.md index e52a3cb1b..f0c3efb1f 100644 --- a/pkgs/checks/CHANGELOG.md +++ b/pkgs/checks/CHANGELOG.md @@ -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 diff --git a/pkgs/checks/doc/migrating_from_matcher.md b/pkgs/checks/doc/migrating_from_matcher.md index 82c4e3c31..b358e63c9 100644 --- a/pkgs/checks/doc/migrating_from_matcher.md +++ b/pkgs/checks/doc/migrating_from_matcher.md @@ -122,9 +122,14 @@ check(because: 'some explanation', actual).expectation(); - `containsPair(key, value)` -> Use `Subject[key].equals(value)` - `hasLength(expected)` -> `length.equals(expected)` - `isNot(Matcher)` -> `not(conditionCallback)` -- `pairwiseCompare` -> `pairwiseComparesTo` +- `pairwiseCompare` -> `pairwiseMatches` - `same` -> `identicalTo` - `stringContainsInOrder` -> `Subject.containsInOrder` +- `containsAllInOrder(iterable)` -> + `Subject.containsMatchingInOrder(iterable)` to compare with + conditions other than equals, + `Subject.containsEqualInOrder(iterable)` to compare each index + with the equality operator (`==`). ### Members from `package:test/expect.dart` without a direct replacement diff --git a/pkgs/checks/lib/src/describe.dart b/pkgs/checks/lib/src/describe.dart index 22eb90ea9..f05a1640b 100644 --- a/pkgs/checks/lib/src/describe.dart +++ b/pkgs/checks/lib/src/describe.dart @@ -61,7 +61,7 @@ Iterable _prettyPrint( .map((line) => line.replaceAll("'", r"\'")) .toList(); return prefixFirst("'", postfixLast("'", escaped)); - } else if (object is Condition) { + } else if (object is Condition) { return ['', describe(object))]; } else { final value = const LineSplitter().convert(object.toString()); diff --git a/pkgs/checks/lib/src/extensions/iterable.dart b/pkgs/checks/lib/src/extensions/iterable.dart index 2ab1bfa03..ecfa9c05e 100644 --- a/pkgs/checks/lib/src/extensions/iterable.dart +++ b/pkgs/checks/lib/src/extensions/iterable.dart @@ -89,6 +89,8 @@ extension IterableChecks on Subject> { /// check([1, 0, 2, 0, 3]) /// .containsInOrder([1, (Subject v) => v.isGreaterThan(1), 3]); /// ``` + @Deprecated('Use `containsEqualInOrder` for expectations with values compared' + ' with `==` or `containsMatchingInOrder` for other expectations') void containsInOrder(Iterable elements) { context.expect(() => prefixFirst('contains, in order: ', literal(elements)), (actual) { @@ -115,6 +117,74 @@ extension IterableChecks on Subject> { }); } + /// 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> 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 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 elementCondition) { @@ -250,7 +320,24 @@ extension IterableChecks on Subject> { /// [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(List expected, + Condition 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(List expected, Condition Function(S) elementCondition, String description) { context.expect(() { return prefixFirst('pairwise $description ', literal(expected)); diff --git a/pkgs/checks/test/extensions/iterable_test.dart b/pkgs/checks/test/extensions/iterable_test.dart index 175919d10..dafd84d74 100644 --- a/pkgs/checks/test/extensions/iterable_test.dart +++ b/pkgs/checks/test/extensions/iterable_test.dart @@ -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 ' + '>' + ]); + }); + test('can be described', () { + check((Subject> it) => it.containsMatchingInOrder([ + (it) => it.isLessThan(2), + (it) => it.isLessThan(3), + (it) => it.isLessThan(4), + ])).description.deepEquals([ + ' contains, in order: [>,', + ' >,', + ' >]', + ]); + check((Subject> it) => it.containsMatchingInOrder( + [(it) => it.equals(1), (it) => it.equals(2)])) + .description + .deepEquals([ + ' contains, in order: [>,', + ' >]' + ]); + }); + }); + + 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> it) => it.containsEqualInOrder([1, 2, 3])) + .description + .deepEquals([' contains, in order: [1, 2, 3]']); + check((Subject> 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)); @@ -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:', @@ -196,7 +259,7 @@ 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' @@ -204,7 +267,7 @@ void main() { }); 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']); });