
* Add hasAGoodToStringDeep and equalsIgnoringHashCodes methods. Methods simplify testing of toStringDeep calls and other cases where methods return strings containing hash codes.
587 lines
19 KiB
Dart
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)');
|
|
}
|