blob: 5c5fdcea53b52addcc45393a2cdd3c2a01fbb7d1 [file] [log] [blame]
// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'feature_matcher.dart';
import 'interfaces.dart';
import 'util.dart';
/// Returns a matcher that matches if the value is structurally equal to
/// [expected].
///
/// If [expected] is a [Matcher], then it matches using that. Otherwise it tests
/// for equality using `==` on the expected value.
///
/// For [Iterable]s and [Map]s, this will recursively match the elements. To
/// handle cyclic structures a recursion depth [limit] can be provided. The
/// default limit is 100. [Set]s will be compared order-independently.
Matcher equals(Object? expected, [int limit = 100]) => expected is String
? _StringEqualsMatcher(expected)
: _DeepMatcher(expected, limit);
typedef _RecursiveMatcher = _Mismatch? Function(Object?, Object?, String, int);
/// A special equality matcher for strings.
class _StringEqualsMatcher extends FeatureMatcher<String> {
final String _value;
_StringEqualsMatcher(this._value);
@override
bool typedMatches(String item, Map matchState) => _value == item;
@override
Description describe(Description description) =>
description.addDescriptionOf(_value);
@override
Description describeTypedMismatch(String item,
Description mismatchDescription, Map matchState, bool verbose) {
var buff = StringBuffer();
buff.write('is different.');
var escapedItem = escape(item);
var escapedValue = escape(_value);
var minLength = escapedItem.length < escapedValue.length
? escapedItem.length
: escapedValue.length;
var start = 0;
for (; start < minLength; start++) {
if (escapedValue.codeUnitAt(start) != escapedItem.codeUnitAt(start)) {
break;
}
}
if (start == minLength) {
if (escapedValue.length < escapedItem.length) {
buff.write(' Both strings start the same, but the actual value also'
' has the following trailing characters: ');
_writeTrailing(buff, escapedItem, escapedValue.length);
} else {
buff.write(' Both strings start the same, but the actual value is'
' missing the following trailing characters: ');
_writeTrailing(buff, escapedValue, escapedItem.length);
}
} else {
buff.write('\nExpected: ');
_writeLeading(buff, escapedValue, start);
_writeTrailing(buff, escapedValue, start);
buff.write('\n Actual: ');
_writeLeading(buff, escapedItem, start);
_writeTrailing(buff, escapedItem, start);
buff.write('\n ');
for (var i = start > 10 ? 14 : start; i > 0; i--) {
buff.write(' ');
}
buff.write('^\n Differ at offset $start');
}
return mismatchDescription.add(buff.toString());
}
static void _writeLeading(StringBuffer buff, String s, int start) {
if (start > 10) {
buff.write('... ');
buff.write(s.substring(start - 10, start));
} else {
buff.write(s.substring(0, start));
}
}
static void _writeTrailing(StringBuffer buff, String s, int start) {
if (start + 10 > s.length) {
buff.write(s.substring(start));
} else {
buff.write(s.substring(start, start + 10));
buff.write(' ...');
}
}
}
class _DeepMatcher extends Matcher {
final Object? _expected;
final int _limit;
_DeepMatcher(this._expected, [int limit = 1000]) : _limit = limit;
_Mismatch? _compareIterables(Iterable expected, Object? actual,
_RecursiveMatcher matcher, int depth, String location) {
if (actual is Iterable) {
var expectedIterator = expected.iterator;
var actualIterator = actual.iterator;
for (var index = 0;; index++) {
// Advance in lockstep.
var expectedNext = expectedIterator.moveNext();
var actualNext = actualIterator.moveNext();
// If we reached the end of both, we succeeded.
if (!expectedNext && !actualNext) return null;
// Fail if their lengths are different.
var newLocation = '$location[$index]';
if (!expectedNext) {
return _Mismatch.simple(newLocation, actual, 'longer than expected');
}
if (!actualNext) {
return _Mismatch.simple(newLocation, actual, 'shorter than expected');
}
// Match the elements.
var rp = matcher(expectedIterator.current, actualIterator.current,
newLocation, depth);
if (rp != null) return rp;
}
} else {
return _Mismatch.simple(location, actual, 'is not Iterable');
}
}
_Mismatch? _compareSets(Set expected, Object? actual,
_RecursiveMatcher matcher, int depth, String location) {
if (actual is Iterable) {
var other = actual.toSet();
for (var expectedElement in expected) {
if (other.every((actualElement) =>
matcher(expectedElement, actualElement, location, depth) != null)) {
return _Mismatch(
location,
actual,
(description, verbose) => description
.add('does not contain ')
.addDescriptionOf(expectedElement));
}
}
if (other.length > expected.length) {
return _Mismatch.simple(location, actual, 'larger than expected');
} else if (other.length < expected.length) {
return _Mismatch.simple(location, actual, 'smaller than expected');
} else {
return null;
}
} else {
return _Mismatch.simple(location, actual, 'is not Iterable');
}
}
_Mismatch? _recursiveMatch(
Object? expected, Object? actual, String location, int depth) {
// If the expected value is a matcher, try to match it.
if (expected is Matcher) {
var matchState = {};
if (expected.matches(actual, matchState)) return null;
return _Mismatch(location, actual, (description, verbose) {
var oldLength = description.length;
expected.describeMismatch(actual, description, matchState, verbose);
if (depth > 0 && description.length == oldLength) {
description.add('does not match ');
expected.describe(description);
}
});
} else {
// Otherwise, test for equality.
try {
if (expected == actual) return null;
} catch (e) {
// TODO(gram): Add a test for this case.
return _Mismatch(
location,
actual,
(description, verbose) =>
description.add('== threw ').addDescriptionOf(e));
}
}
if (depth > _limit) {
return _Mismatch.simple(
location, actual, 'recursion depth limit exceeded');
}
// If _limit is 1 we can only recurse one level into object.
if (depth == 0 || _limit > 1) {
if (expected is Set) {
return _compareSets(
expected, actual, _recursiveMatch, depth + 1, location);
} else if (expected is Iterable) {
return _compareIterables(
expected, actual, _recursiveMatch, depth + 1, location);
} else if (expected is Map) {
if (actual is! Map) {
return _Mismatch.simple(location, actual, 'expected a map');
}
var err = (expected.length == actual.length)
? ''
: 'has different length and ';
for (var key in expected.keys) {
if (!actual.containsKey(key)) {
return _Mismatch(
location,
actual,
(description, verbose) => description
.add('${err}is missing map key ')
.addDescriptionOf(key));
}
}
for (var key in actual.keys) {
if (!expected.containsKey(key)) {
return _Mismatch(
location,
actual,
(description, verbose) => description
.add('${err}has extra map key ')
.addDescriptionOf(key));
}
}
for (var key in expected.keys) {
var rp = _recursiveMatch(
expected[key], actual[key], "$location['$key']", depth + 1);
if (rp != null) return rp;
}
return null;
}
}
// If we have recursed, show the expected value too; if not, expect() will
// show it for us.
if (depth > 0) {
return _Mismatch(location, actual,
(description, verbose) => description.addDescriptionOf(expected),
instead: true);
} else {
return _Mismatch(location, actual, null);
}
}
@override
bool matches(Object? actual, Map matchState) {
var mismatch = _recursiveMatch(_expected, actual, '', 0);
if (mismatch == null) return true;
addStateInfo(matchState, {'mismatch': mismatch});
return false;
}
@override
Description describe(Description description) =>
description.addDescriptionOf(_expected);
@override
Description describeMismatch(Object? item, Description mismatchDescription,
Map matchState, bool verbose) {
var mismatch = matchState['mismatch'] as _Mismatch;
var describeProblem = mismatch.describeProblem;
if (mismatch.location.isNotEmpty) {
mismatchDescription
.add('at location ')
.add(mismatch.location)
.add(' is ')
.addDescriptionOf(mismatch.actual);
if (describeProblem != null) {
mismatchDescription
.add(' ${mismatch.instead ? 'instead of' : 'which'} ');
describeProblem(mismatchDescription, verbose);
}
} else {
// If we didn't get a good reason, that would normally be a
// simple 'is <value>' message. We only add that if the mismatch
// description is non empty (so we are supplementing the mismatch
// description).
if (describeProblem == null) {
if (mismatchDescription.length > 0) {
mismatchDescription.add('is ').addDescriptionOf(item);
}
} else {
describeProblem(mismatchDescription, verbose);
}
}
return mismatchDescription;
}
}
class _Mismatch {
/// A human-readable description of the location within the collection where
/// the mismatch occurred.
final String location;
/// The actual value found at [location].
final Object? actual;
/// Callback that can create a detailed description of the problem.
final void Function(Description, bool verbose)? describeProblem;
/// If `true`, [describeProblem] describes the expected value, so when the
/// final mismatch description is pieced together, it will be preceded by
/// `instead of` (e.g. `at location [2] is <3> instead of <4>`). If `false`,
/// [describeProblem] is a problem description from a sub-matcher, so when the
/// final mismatch description is pieced together, it will be preceded by
/// `which` (e.g. `at location [2] is <foo> which has length of 3`).
final bool instead;
_Mismatch(this.location, this.actual, this.describeProblem,
{this.instead = false});
_Mismatch.simple(this.location, this.actual, String problem,
{this.instead = false})
: describeProblem = ((description, verbose) => description.add(problem));
}