Jacob Richman 8f07a5864b Add hasAGoodToStringDeep and equalsIgnoringHashCodes methods. (#10935)
* Add hasAGoodToStringDeep and equalsIgnoringHashCodes methods.

Methods simplify testing of toStringDeep calls and other cases where
methods return strings containing hash codes.
2017-06-23 14:07:09 -07:00

587 lines
19 KiB
Dart

// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:test/test.dart';
import 'finders.dart';
/// Asserts that the [Finder] matches no widgets in the widget tree.
///
/// ## Sample code
///
/// ```dart
/// expect(find.text('Save'), findsNothing);
/// ```
///
/// See also:
///
/// * [findsWidgets], when you want the finder to find one or more widgets.
/// * [findsOneWidget], when you want the finder to find exactly one widget.
/// * [findsNWidgets], when you want the finder to find a specific number of widgets.
const Matcher findsNothing = const _FindsWidgetMatcher(null, 0);
/// Asserts that the [Finder] locates at least one widget in the widget tree.
///
/// ## Sample code
///
/// ```dart
/// expect(find.text('Save'), findsWidgets);
/// ```
///
/// See also:
///
/// * [findsNothing], when you want the finder to not find anything.
/// * [findsOneWidget], when you want the finder to find exactly one widget.
/// * [findsNWidgets], when you want the finder to find a specific number of widgets.
const Matcher findsWidgets = const _FindsWidgetMatcher(1, null);
/// Asserts that the [Finder] locates at exactly one widget in the widget tree.
///
/// ## Sample code
///
/// ```dart
/// expect(find.text('Save'), findsOneWidget);
/// ```
///
/// See also:
///
/// * [findsNothing], when you want the finder to not find anything.
/// * [findsWidgets], when you want the finder to find one or more widgets.
/// * [findsNWidgets], when you want the finder to find a specific number of widgets.
const Matcher findsOneWidget = const _FindsWidgetMatcher(1, 1);
/// Asserts that the [Finder] locates the specified number of widgets in the widget tree.
///
/// ## Sample code
///
/// ```dart
/// expect(find.text('Save'), findsNWidgets(2));
/// ```
///
/// See also:
///
/// * [findsNothing], when you want the finder to not find anything.
/// * [findsWidgets], when you want the finder to find one or more widgets.
/// * [findsOneWidget], when you want the finder to find exactly one widget.
Matcher findsNWidgets(int n) => new _FindsWidgetMatcher(n, n);
/// Asserts that the [Finder] locates the a single widget that has at
/// least one [Offstage] widget ancestor.
///
/// It's important to use a full finder, since by default finders exclude
/// offstage widgets.
///
/// ## Sample code
///
/// ```dart
/// expect(find.text('Save', skipOffstage: false), isOffstage);
/// ```
///
/// See also:
///
/// * [isOnstage], the opposite.
const Matcher isOffstage = const _IsOffstage();
/// Asserts that the [Finder] locates the a single widget that has no
/// [Offstage] widget ancestors.
///
/// See also:
///
/// * [isOffstage], the opposite.
const Matcher isOnstage = const _IsOnstage();
/// Asserts that the [Finder] locates the a single widget that has at
/// least one [Card] widget ancestor.
///
/// See also:
///
/// * [isNotInCard], the opposite.
const Matcher isInCard = const _IsInCard();
/// Asserts that the [Finder] locates the a single widget that has no
/// [Card] widget ancestors.
///
/// This is equivalent to `isNot(isInCard)`.
///
/// See also:
///
/// * [isInCard], the opposite.
const Matcher isNotInCard = const _IsNotInCard();
/// Asserts that an object's toString() is a plausible one-line description.
///
/// Specifically, this matcher checks that the string does not contains newline
/// characters, and does not have leading or trailing whitespace, is not
/// empty, and does not contain the default `Instance of ...` string.
const Matcher hasOneLineDescription = const _HasOneLineDescription();
/// Asserts that an object's toStringDeep() is a plausible multi-line
/// description.
///
/// Specifically, this matcher checks that an object's
/// `toStringDeep(prefixLineOne, prefixOtherLines)`:
///
/// * Does not have leading or trailing whitespace.
/// * Does not contain the default `Instance of ...` string.
/// * The last line has characters other than tree connector characters and
/// whitespace. For example: the line ` │ ║ ╎` has only tree connector
/// characters and whitespace.
/// * Does not contain lines with trailing white space.
/// * Has multiple lines.
/// * The first line starts with `prefixLineOne`
/// * All subsequent lines start with `prefixOtherLines`.
const Matcher hasAGoodToStringDeep = const _HasGoodToStringDeep();
/// A matcher for functions that throw [FlutterError].
///
/// This is equivalent to `throwsA(const isInstanceOf<FlutterError>())`.
///
/// See also:
///
/// * [throwsAssertionError], to test if a function throws any [AssertionError].
/// * [isFlutterError], to test if any object is a [FlutterError].
/// * [isAssertionError], to test if any object is any kind of [AssertionError].
Matcher throwsFlutterError = throwsA(isFlutterError);
/// A matcher for functions that throw [AssertionError].
///
/// This is equivalent to `throwsA(const isInstanceOf<AssertionError>())`.
///
/// See also:
///
/// * [throwsFlutterError], to test if a function throws a [FlutterError].
/// * [isFlutterError], to test if any object is a [FlutterError].
/// * [isAssertionError], to test if any object is any kind of [AssertionError].
Matcher throwsAssertionError = throwsA(isAssertionError);
/// A matcher for [FlutterError].
///
/// This is equivalent to `const isInstanceOf<FlutterError>()`.
///
/// See also:
///
/// * [throwsFlutterError], to test if a function throws a [FlutterError].
/// * [throwsAssertionError], to test if a function throws any [AssertionError].
/// * [isAssertionError], to test if any object is any kind of [AssertionError].
const Matcher isFlutterError = const isInstanceOf<FlutterError>();
/// A matcher for [AssertionError].
///
/// This is equivalent to `const isInstanceOf<AssertionError>()`.
///
/// See also:
///
/// * [throwsFlutterError], to test if a function throws a [FlutterError].
/// * [throwsAssertionError], to test if a function throws any [AssertionError].
/// * [isFlutterError], to test if any object is a [FlutterError].
const Matcher isAssertionError = const isInstanceOf<AssertionError>();
/// Asserts that two [double]s are equal, within some tolerated error.
///
/// Two values are considered equal if the difference between them is within
/// 1e-10 of the larger one. This is an arbitrary value which can be adjusted
/// using the `epsilon` argument. This matcher is intended to compare floating
/// point numbers that are the result of different sequences of operations, such
/// that they may have accumulated slightly different errors.
///
/// See also:
///
/// * [closeTo], which is identical except that the epsilon argument is
/// required and not named.
/// * [inInclusiveRange], which matches if the argument is in a specified
/// range.
Matcher moreOrLessEquals(double value, { double epsilon: 1e-10 }) {
return new _MoreOrLessEquals(value, epsilon);
}
/// Asserts that two [String]s are equal after normalizing likely hash codes.
///
/// A `#` followed by 5 hexadecimal digits is assumed to be a short hash code
/// and is normalized to #00000.
///
/// See Also:
///
/// * [describeIdentity], a method that generates short descriptions of objects
/// with ids that match the pattern #[0-9a-f]{5}.
/// * [shortHash], a method that generates a 5 character long hexadecimal
/// [String] based on [Object.hashCode].
/// * [TreeDiagnosticsMixin.toStringDeep], a method that returns a [String]
/// typically containing multiple hash codes.
Matcher equalsIgnoringHashCodes(String value) {
return new _EqualsIgnoringHashCodes(value);
}
class _FindsWidgetMatcher extends Matcher {
const _FindsWidgetMatcher(this.min, this.max);
final int min;
final int max;
@override
bool matches(covariant Finder finder, Map<dynamic, dynamic> matchState) {
assert(min != null || max != null);
assert(min == null || max == null || min <= max);
matchState[Finder] = finder;
int count = 0;
final Iterator<Element> iterator = finder.evaluate().iterator;
if (min != null) {
while (count < min && iterator.moveNext())
count += 1;
if (count < min)
return false;
}
if (max != null) {
while (count <= max && iterator.moveNext())
count += 1;
if (count > max)
return false;
}
return true;
}
@override
Description describe(Description description) {
assert(min != null || max != null);
if (min == max) {
if (min == 1)
return description.add('exactly one matching node in the widget tree');
return description.add('exactly $min matching nodes in the widget tree');
}
if (min == null) {
if (max == 0)
return description.add('no matching nodes in the widget tree');
if (max == 1)
return description.add('at most one matching node in the widget tree');
return description.add('at most $max matching nodes in the widget tree');
}
if (max == null) {
if (min == 1)
return description.add('at least one matching node in the widget tree');
return description.add('at least $min matching nodes in the widget tree');
}
return description.add('between $min and $max matching nodes in the widget tree (inclusive)');
}
@override
Description describeMismatch(
dynamic item,
Description mismatchDescription,
Map<dynamic, dynamic> matchState,
bool verbose
) {
final Finder finder = matchState[Finder];
final int count = finder.evaluate().length;
if (count == 0) {
assert(min != null && min > 0);
if (min == 1 && max == 1)
return mismatchDescription.add('means none were found but one was expected');
return mismatchDescription.add('means none were found but some were expected');
}
if (max == 0) {
if (count == 1)
return mismatchDescription.add('means one was found but none were expected');
return mismatchDescription.add('means some were found but none were expected');
}
if (min != null && count < min)
return mismatchDescription.add('is not enough');
assert(max != null && count > min);
return mismatchDescription.add('is too many');
}
}
bool _hasAncestorMatching(Finder finder, bool predicate(Widget widget)) {
final Iterable<Element> nodes = finder.evaluate();
if (nodes.length != 1)
return false;
bool result = false;
nodes.single.visitAncestorElements((Element ancestor) {
if (predicate(ancestor.widget)) {
result = true;
return false;
}
return true;
});
return result;
}
bool _hasAncestorOfType(Finder finder, Type targetType) {
return _hasAncestorMatching(finder, (Widget widget) => widget.runtimeType == targetType);
}
class _IsOffstage extends Matcher {
const _IsOffstage();
@override
bool matches(covariant Finder finder, Map<dynamic, dynamic> matchState) {
return _hasAncestorMatching(finder, (Widget widget) {
if (widget is Offstage)
return widget.offstage;
return false;
});
}
@override
Description describe(Description description) => description.add('offstage');
}
class _IsOnstage extends Matcher {
const _IsOnstage();
@override
bool matches(covariant Finder finder, Map<dynamic, dynamic> matchState) {
final Iterable<Element> nodes = finder.evaluate();
if (nodes.length != 1)
return false;
bool result = true;
nodes.single.visitAncestorElements((Element ancestor) {
final Widget widget = ancestor.widget;
if (widget is Offstage) {
result = !widget.offstage;
return false;
}
return true;
});
return result;
}
@override
Description describe(Description description) => description.add('onstage');
}
class _IsInCard extends Matcher {
const _IsInCard();
@override
bool matches(covariant Finder finder, Map<dynamic, dynamic> matchState) => _hasAncestorOfType(finder, Card);
@override
Description describe(Description description) => description.add('in card');
}
class _IsNotInCard extends Matcher {
const _IsNotInCard();
@override
bool matches(covariant Finder finder, Map<dynamic, dynamic> matchState) => !_hasAncestorOfType(finder, Card);
@override
Description describe(Description description) => description.add('not in card');
}
class _HasOneLineDescription extends Matcher {
const _HasOneLineDescription();
@override
bool matches(Object object, Map<dynamic, dynamic> matchState) {
final String description = object.toString();
return description.isNotEmpty
&& !description.contains('\n')
&& !description.contains('Instance of ')
&& description.trim() == description;
}
@override
Description describe(Description description) => description.add('one line description');
}
class _EqualsIgnoringHashCodes extends Matcher {
_EqualsIgnoringHashCodes(String v) : _value = _normalize(v);
final String _value;
static final Object _mismatchedValueKey = new Object();
static String _normalize(String s) {
return s.replaceAll(new RegExp(r'#[0-9a-f]{5}'), '#00000');
}
@override
bool matches(dynamic object, Map<dynamic, dynamic> matchState) {
final String description = _normalize(object);
if (_value != description) {
matchState[_mismatchedValueKey] = description;
return false;
}
return true;
}
@override
Description describe(Description description) {
return description.add('multi line description equals $_value');
}
@override
Description describeMismatch(
dynamic item,
Description mismatchDescription,
Map<dynamic, dynamic> matchState,
bool verbose
) {
if (matchState.containsKey(_mismatchedValueKey)) {
final String actualValue = matchState[_mismatchedValueKey];
// Leading whitespace is added so that lines in the multi-line
// description returned by addDescriptionOf are all indented equally
// which makes the output easier to read for this case.
return mismatchDescription
.add('expected normalized value\n ')
.addDescriptionOf(_value)
.add('\nbut got\n ')
.addDescriptionOf(actualValue);
}
return mismatchDescription;
}
}
/// Returns `true` if [c] represents a whitespace code unit.
bool _isWhitespace(int c) => (c <= 0x000D && c >= 0x0009) || c == 0x0020;
/// Returns `true` if [c] represents a vertical line unicode line art code unit.
///
/// See [https://en.wikipedia.org/wiki/Box-drawing_character]. This method only
/// specifies vertical line art code units currently used by Flutter line art.
/// There are other line art characters that technically also represent vertical
/// lines.
bool _isVerticalLine(int c) {
return c == 0x2502 || c == 0x2503 || c == 0x2551 || c == 0x254e;
}
/// Returns whether a [line] is all vertical tree connector characters.
///
/// Example vertical tree connector characters: `│ ║ ╎`.
/// The last line of a text tree contains only vertical tree connector
/// characters indicates a poorly formatted tree.
bool _isAllTreeConnectorCharacters(String line) {
for (int i = 0; i < line.length; ++i) {
final int c = line.codeUnitAt(i);
if (!_isWhitespace(c) && !_isVerticalLine(c))
return false;
}
return true;
}
class _HasGoodToStringDeep extends Matcher {
const _HasGoodToStringDeep();
static final Object _toStringDeepErrorDescriptionKey = new Object();
@override
bool matches(dynamic object, Map<dynamic, dynamic> matchState) {
final List<String> issues = <String>[];
String description = object.toStringDeep();
if (description.endsWith('\n')) {
// Trim off trailing \n as the remaining calculations assume
// the description does not end with a trailing \n.
description = description.substring(0, description.length - 1);
} else {
issues.add('Not terminated with a line break.');
}
if (description.trim() != description)
issues.add('Has trailing whitespace.');
final List<String> lines = description.split('\n');
if (lines.length < 2)
issues.add('Does not have multiple lines.');
if (description.contains('Instance of '))
issues.add('Contains text "Instance of ".');
for (int i = 0; i < lines.length; ++i) {
final String line = lines[i];
if (line.isEmpty)
issues.add('Line ${i+1} is empty.');
if (line.trimRight() != line)
issues.add('Line ${i+1} has trailing whitespace.');
}
if (_isAllTreeConnectorCharacters(lines.last))
issues.add('Last line is all tree connector characters.');
// If a toStringDeep method doesn't properly handle nested values that
// contain line breaks it can fail to add the required prefixes to all
// lined when toStringDeep is called specifying prefixes.
final String prefixLineOne = 'PREFIX_LINE_ONE____';
final String prefixOtherLines = 'PREFIX_OTHER_LINES_';
final List<String> prefixIssues = <String>[];
String descriptionWithPrefixes =
object.toStringDeep(prefixLineOne, prefixOtherLines);
if (descriptionWithPrefixes.endsWith('\n')) {
// Trim off trailing \n as the remaining calculations assume
// the description does not end with a trailing \n.
descriptionWithPrefixes = descriptionWithPrefixes.substring(
0, descriptionWithPrefixes.length - 1);
}
final List<String> linesWithPrefixes = descriptionWithPrefixes.split('\n');
if (!linesWithPrefixes.first.startsWith(prefixLineOne))
prefixIssues.add('First line does not contain expected prefix.');
for (int i = 1; i < linesWithPrefixes.length; ++i) {
if (!linesWithPrefixes[i].startsWith(prefixOtherLines))
prefixIssues.add('Line ${i+1} does not contain the expected prefix.');
}
final StringBuffer errorDescription = new StringBuffer();
if (issues.isNotEmpty) {
errorDescription.writeln('Bad toStringDeep():');
errorDescription.writeln(description);
errorDescription.writeAll(issues, '\n');
}
if (prefixIssues.isNotEmpty) {
errorDescription.writeln(
'Bad toStringDeep("$prefixLineOne", "$prefixOtherLines"):');
errorDescription.writeln(descriptionWithPrefixes);
errorDescription.writeAll(prefixIssues, '\n');
}
if (errorDescription.isNotEmpty) {
matchState[_toStringDeepErrorDescriptionKey] =
errorDescription.toString();
return false;
}
return true;
}
@override
Description describeMismatch(
dynamic item,
Description mismatchDescription,
Map<dynamic, dynamic> matchState,
bool verbose
) {
if (matchState.containsKey(_toStringDeepErrorDescriptionKey)) {
return mismatchDescription.add(
matchState[_toStringDeepErrorDescriptionKey]);
}
return mismatchDescription;
}
@override
Description describe(Description description) {
return description.add('multi line description');
}
}
class _MoreOrLessEquals extends Matcher {
const _MoreOrLessEquals(this.value, this.epsilon);
final double value;
final double epsilon;
@override
bool matches(Object object, Map<dynamic, dynamic> matchState) {
if (object is! double)
return false;
if (object == value)
return true;
final double test = object;
return (test - value).abs() <= epsilon;
}
@override
Description describe(Description description) => description.add('$value$epsilon)');
}