diff --git a/pkgs/checks/CHANGELOG.md b/pkgs/checks/CHANGELOG.md index 726d51c04..f0c3efb1f 100644 --- a/pkgs/checks/CHANGELOG.md +++ b/pkgs/checks/CHANGELOG.md @@ -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 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/collection_equality.dart b/pkgs/checks/lib/src/collection_equality.dart index 93281270c..da6064a52 100644 --- a/pkgs/checks/lib/src/collection_equality.dart +++ b/pkgs/checks/lib/src/collection_equality.dart @@ -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` or `Subject` and /// may not use a more specific generic. @@ -24,14 +24,25 @@ import '../context.dart'; /// Note also that the argument type `Subject` 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. @@ -70,7 +81,7 @@ Iterable? _deepCollectionEquals( } else { currentExpected as Map; rejectionWhich = _findMapDifference( - currentActual, currentExpected, path, currentDepth); + currentActual, currentExpected, path, queue, currentDepth); } if (rejectionWhich != null) return rejectionWhich; } @@ -100,32 +111,38 @@ List? _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? _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; @@ -165,39 +182,82 @@ Iterable? _findSetDifference( } Iterable? _findMapDifference( - Object? actual, Map expected, _Path path, int depth) { + Object? actual, + Map expected, + _Path path, + Queue<_Search> queue, + int depth) { if (actual is! Map) { return ['${path}is not a Map']; } - Iterable describeEntry(MapEntry 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 _describeEntry(MapEntry 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? _findUnambiguousMapDifference( + Map actual, + Map 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? _findAmbiguousMapDifference(Map actual, + Map 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; 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/collection_equality_test.dart b/pkgs/checks/test/extensions/collection_equality_test.dart index 516357bbc..28a10dd98 100644 --- a/pkgs/checks/test/extensions/collection_equality_test.dart +++ b/pkgs/checks/test/extensions/collection_equality_test.dart @@ -129,9 +129,9 @@ void main() { }, { 'a': (Subject it) => it.isA().startsWith('a') })).isNotNull().deepEquals([ - "has no entry to match 'a': ", + "has an element at ['a'] that:", + " Actual: 'b'", + " which does not start with 'a'", ]); }); @@ -147,6 +147,21 @@ void main() { ]); }); + test('maintains paths through maps when the keys are all values', () { + check(deepCollectionEquals({ + 'a': [ + {'b': 'c'} + ] + }, { + 'a': [ + {'b': 'd'} + ] + })).isNotNull().deepEquals([ + "at ['a'][<0>]['b'] is 'c'", + "which does not equal 'd'", + ]); + }); + test('reports recursive lists', () { var l = []; l.add(l); 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']); }); diff --git a/pkgs/test/lib/src/runner/browser/static/run_wasm_chrome.js b/pkgs/test/lib/src/runner/browser/static/run_wasm_chrome.js index d8628b9fe..6e3bda02b 100644 --- a/pkgs/test/lib/src/runner/browser/static/run_wasm_chrome.js +++ b/pkgs/test/lib/src/runner/browser/static/run_wasm_chrome.js @@ -7,15 +7,41 @@ // affect chrome. (async () => { // Fetch and compile Wasm binary. - let data = document.getElementById('WasmBootstrapInfo').dataset; - let modulePromise = WebAssembly.compileStreaming(fetch(data.wasmurl)); + let data = document.getElementById("WasmBootstrapInfo").dataset; // Instantiate the Dart module, importing from the global scope. - let dart2wasm = await import('./' + data.jsruntimeurl); - let dartInstance = await dart2wasm.instantiate(modulePromise, {}); + let dart2wasmJsRuntime = await import("./" + data.jsruntimeurl); - // Call `main`. If tasks are placed into the event loop (by scheduling tasks - // explicitly or awaiting Futures), these will automatically keep the script - // alive even after `main` returns. - await dart2wasm.invoke(dartInstance); + // Support three versions of dart2wasm: + // + // (1) Versions before 3.6.0-167.0.dev require the user to compile using the + // browser's `WebAssembly` API, the compiled module needs to be instantiated + // using the JS runtime. + // + // (2) Versions starting with 3.6.0-167.0.dev added helpers for compiling and + // instantiating. + // + // (3) Versions starting with 3.6.0-212.0.dev made compilation functions + // return a new type that comes with instantiation and invoke methods. + + if (dart2wasmJsRuntime.compileStreaming !== undefined) { + // Version (2) or (3). + let compiledModule = await dart2wasmJsRuntime.compileStreaming( + fetch(data.wasmurl), + ); + if (compiledModule.instantiate !== undefined) { + // Version (3). + let instantiatedModule = await compiledModule.instantiate(); + instantiatedModule.invokeMain(); + } else { + // Version (2). + let dartInstance = await dart2wasmJsRuntime.instantiate(compiledModule, {}); + await dart2wasmJsRuntime.invoke(dartInstance); + } + } else { + // Version (1). + let modulePromise = WebAssembly.compileStreaming(fetch(data.wasmurl)); + let dartInstance = await dart2wasmJsRuntime.instantiate(modulePromise, {}); + await dart2wasmJsRuntime.invoke(dartInstance); + } })();