Skip to content

Commit

Permalink
Merge branch 'master' into dependabot/github_actions/github-actions-b…
Browse files Browse the repository at this point in the history
…6c4674bda
  • Loading branch information
natebosch authored Oct 2, 2024
2 parents 2601191 + df3e2f1 commit 7114c65
Show file tree
Hide file tree
Showing 8 changed files with 343 additions and 81 deletions.
8 changes: 7 additions & 1 deletion pkgs/checks/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
## 0.3.1-wip

- Update min SDK constraint to 3.4.0.
- Directly compare keys across actual and expected `Map` instances when
checking deep collection equality and all the keys can be directly compared
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
184 changes: 122 additions & 62 deletions pkgs/checks/lib/src/collection_equality.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ import '../context.dart';
/// {@template deep_collection_equals}
/// Elements, keys, or values within [expected] which are a collections are
/// deeply compared for equality with a collection in the same position within
/// [actual]. Elements which are collection types are note compared with the
/// [actual]. Elements which are collection types are not compared with the
/// native identity based equality or custom equality operator overrides.
///
/// Elements, keys, or values within [expected] which are `Condition` callbacks
/// Elements, keys, or values within [expected] which are [Condition] callbacks
/// are run against the value in the same position within [actual].
/// Condition callbacks must take a `Subject<Object?>` or `Subject<dynamic>` and
/// may not use a more specific generic.
Expand All @@ -24,14 +24,25 @@ import '../context.dart';
/// Note also that the argument type `Subject<Object?>` cannot be inferred and
/// must be explicit in the function definition.
///
/// Elements, keys, or values within [expected] which are any other type are
/// compared using `operator ==` equality.
/// Elements and values within [expected] which are any other type are compared
/// using `operator ==` equality.
///
/// Comparing sets or maps will have a runtime which is polynomial on the the
/// size of those collections. Does not use [Set.contains] or [Map.containsKey],
/// there will not be runtime benefits from hashing. Custom collection behavior
/// is ignored. For example, it is not possible to distinguish between a `Set`
/// and a `Set.identity`.
/// Deep equality checks for [Map] instances depend on the structure of the
/// expected map.
/// If all keys of the expectation are non-collection and non-condition values
/// the keys are compared through the map instances with [Map.containsKey].
///
/// If any key in the expectation is a `Condition` or a collection type the map
/// will be compared as a [Set] of entries where both the key and value must
/// match.
///
/// Comparing sets or maps with complex keys has a runtime which is polynomial
/// on the the size of those collections.
/// These comparisons do not use [Set.contains] or [Map.containsKey],
/// and there will not be runtime benefits from hashing.
/// Custom collection behavior of `contains` is ignored.
/// For example, it is not possible to distinguish between a `Set` and a
/// `Set.identity`.
///
/// Collections may be nested to a maximum depth of 1000. Recursive collections
/// are not allowed.
Expand Down Expand Up @@ -70,7 +81,7 @@ Iterable<String>? _deepCollectionEquals(
} else {
currentExpected as Map;
rejectionWhich = _findMapDifference(
currentActual, currentExpected, path, currentDepth);
currentActual, currentExpected, path, queue, currentDepth);
}
if (rejectionWhich != null) return rejectionWhich;
}
Expand Down Expand Up @@ -100,32 +111,38 @@ List<String>? _findIterableDifference(Object? actual,
'expected an iterable with at least ${index + 1} element(s)'
];
}
var actualValue = actualIterator.current;
var expectedValue = expectedIterator.current;
if (expectedValue is Iterable || expectedValue is Map) {
if (depth + 1 > _maxDepth) throw _ExceededDepthError();
queue.addLast(
_Search(path.append(index), actualValue, expectedValue, depth + 1));
} else if (expectedValue is Condition) {
final failure = softCheck(actualValue, expectedValue);
if (failure != null) {
final which = failure.rejection.which;
return [
'has an element ${path.append(index)}that:',
...indent(failure.detail.actual.skip(1)),
...indent(prefixFirst('Actual: ', failure.rejection.actual),
failure.detail.depth + 1),
if (which != null)
...indent(prefixFirst('which ', which), failure.detail.depth + 1)
];
}
} else {
if (actualValue != expectedValue) {
return [
...prefixFirst('${path.append(index)}is ', literal(actualValue)),
...prefixFirst('which does not equal ', literal(expectedValue))
];
}
final difference = _compareValue(actualIterator.current,
expectedIterator.current, path, index, queue, depth);
if (difference != null) return difference;
}
return null;
}

List<String>? _compareValue(Object? actualValue, Object? expectedValue,
_Path path, Object? pathAppend, Queue<_Search> queue, int depth) {
if (expectedValue is Iterable || expectedValue is Map) {
if (depth + 1 > _maxDepth) throw _ExceededDepthError();
queue.addLast(_Search(
path.append(pathAppend), actualValue, expectedValue, depth + 1));
} else if (expectedValue is Condition) {
final failure = softCheck(actualValue, expectedValue);
if (failure != null) {
final which = failure.rejection.which;
return [
'has an element ${path.append(pathAppend)}that:',
...indent(failure.detail.actual.skip(1)),
...indent(prefixFirst('Actual: ', failure.rejection.actual),
failure.detail.depth + 1),
if (which != null)
...indent(prefixFirst('which ', which), failure.detail.depth + 1)
];
}
} else {
if (actualValue != expectedValue) {
return [
...prefixFirst('${path.append(pathAppend)}is ', literal(actualValue)),
...prefixFirst('which does not equal ', literal(expectedValue))
];
}
}
return null;
Expand Down Expand Up @@ -165,39 +182,82 @@ Iterable<String>? _findSetDifference(
}

Iterable<String>? _findMapDifference(
Object? actual, Map<Object?, Object?> expected, _Path path, int depth) {
Object? actual,
Map<Object?, Object?> expected,
_Path path,
Queue<_Search> queue,
int depth) {
if (actual is! Map) {
return ['${path}is not a Map'];
}
Iterable<String> describeEntry(MapEntry<Object?, Object?> entry) {
final key = literal(entry.key);
final value = literal(entry.value);
return [
...key.take(key.length - 1),
'${key.last}: ${value.first}',
...value.skip(1)
];
if (expected.keys
.any((key) => key is Condition || key is Iterable || key is Map)) {
return _findAmbiguousMapDifference(actual, expected, path, depth);
} else {
return _findUnambiguousMapDifference(actual, expected, path, queue, depth);
}
}

return unorderedCompare(
actual.entries,
expected.entries,
(actual, expected) =>
_elementMatches(actual.key, expected.key, depth) &&
_elementMatches(actual.value, expected.value, depth),
(expectedEntry, _, count) => [
...prefixFirst(
'${path}has no entry to match ', describeEntry(expectedEntry)),
if (count > 1) 'or ${count - 1} other entries',
],
(actualEntry, _, count) => [
...prefixFirst(
'${path}has unexpected entry ', describeEntry(actualEntry)),
if (count > 1) 'and ${count - 1} other unexpected entries',
],
);
Iterable<String> _describeEntry(MapEntry<Object?, Object?> entry) {
final key = literal(entry.key);
final value = literal(entry.value);
return [
...key.take(key.length - 1),
'${key.last}: ${value.first}',
...value.skip(1)
];
}

/// Returns a description of a difference found between [actual] and [expected]
/// when [expected] has only direct key values and there is a 1:1 mapping
/// between an expected value and a checked value in the map.
Iterable<String>? _findUnambiguousMapDifference(
Map<Object?, Object?> actual,
Map<Object?, Object?> expected,
_Path path,
Queue<_Search> queue,
int depth) {
for (final entry in expected.entries) {
assert(entry.key is! Condition);
assert(entry.key is! Iterable);
assert(entry.key is! Map);
if (!actual.containsKey(entry.key)) {
return prefixFirst(
'${path}has no key matching expected entry ', _describeEntry(entry));
}
final difference = _compareValue(
actual[entry.key], entry.value, path, entry.key, queue, depth);
if (difference != null) return difference;
}
for (final entry in actual.entries) {
if (!expected.containsKey(entry.key)) {
return prefixFirst(
'${path}has an unexpected key for entry ', _describeEntry(entry));
}
}
return null;
}

Iterable<String>? _findAmbiguousMapDifference(Map<Object?, Object?> actual,
Map<Object?, Object?> expected, _Path path, int depth) =>
unorderedCompare(
actual.entries,
expected.entries,
(actual, expected) =>
_elementMatches(actual.key, expected.key, depth) &&
_elementMatches(actual.value, expected.value, depth),
(expectedEntry, _, count) => [
...prefixFirst(
'${path}has no entry to match ', _describeEntry(expectedEntry)),
if (count > 1) 'or ${count - 1} other entries',
],
(actualEntry, _, count) => [
...prefixFirst(
'${path}has unexpected entry ', _describeEntry(actualEntry)),
if (count > 1) 'and ${count - 1} other unexpected entries',
],
);

class _Path {
final _Path? parent;
final Object? index;
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
Loading

0 comments on commit 7114c65

Please sign in to comment.