From 8f07a5864bc250687ea27f373963b89e1fc69efd Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Fri, 23 Jun 2017 14:07:09 -0700 Subject: [PATCH] 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. --- packages/flutter_test/lib/src/matchers.dart | 213 ++++++++++++++++++ packages/flutter_test/test/matchers_test.dart | 144 ++++++++++++ 2 files changed, 357 insertions(+) diff --git a/packages/flutter_test/lib/src/matchers.dart b/packages/flutter_test/lib/src/matchers.dart index ea9f515766..c7b8e0e027 100644 --- a/packages/flutter_test/lib/src/matchers.dart +++ b/packages/flutter_test/lib/src/matchers.dart @@ -117,6 +117,23 @@ const Matcher isNotInCard = const _IsNotInCard(); /// 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())`. @@ -179,6 +196,23 @@ 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); @@ -352,6 +386,185 @@ class _HasOneLineDescription extends Matcher { 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 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 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 matchState) { + final List issues = []; + 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 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 prefixIssues = []; + 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 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 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); diff --git a/packages/flutter_test/test/matchers_test.dart b/packages/flutter_test/test/matchers_test.dart index 64ed12747c..b382463ea6 100644 --- a/packages/flutter_test/test/matchers_test.dart +++ b/packages/flutter_test/test/matchers_test.dart @@ -4,6 +4,43 @@ import 'package:flutter_test/flutter_test.dart'; +/// Class that makes it easy to mock common toStringDeep behavior. +class _MockToStringDeep { + _MockToStringDeep(String str) { + final List lines = str.split('\n'); + _lines = []; + for (int i = 0; i < lines.length - 1; ++i) + _lines.add('${lines[i]}\n'); + + // If the last line is empty, that really just means that the previous + // line was terminated with a line break. + if (lines.isNotEmpty && lines.last.isNotEmpty) { + _lines.add(lines.last); + } + } + + _MockToStringDeep.fromLines(this._lines); + + /// Lines in the message to display when [toStringDeep] is called. + /// For correct toStringDeep behavior, each line should be terminated with a + /// line break. + List _lines; + + String toStringDeep([String prefixLineOne="", String prefixOtherLines=""]) { + final StringBuffer sb = new StringBuffer(); + if (_lines.isNotEmpty) + sb.write('$prefixLineOne${_lines.first}'); + + for (int i = 1; i < _lines.length; ++i) + sb.write('$prefixOtherLines${_lines[i]}'); + + return sb.toString(); + } + + @override + String toString() => toStringDeep(); +} + void main() { test('hasOneLineDescription', () { expect('Hello', hasOneLineDescription); @@ -13,6 +50,113 @@ void main() { expect(new Object(), isNot(hasOneLineDescription)); }); + test('hasAGoodToStringDeep', () { + expect(new _MockToStringDeep('Hello\n World\n'), hasAGoodToStringDeep); + // Not terminated with a line break. + expect(new _MockToStringDeep('Hello\n World'), isNot(hasAGoodToStringDeep)); + // Trailing whitespace on last line. + expect(new _MockToStringDeep('Hello\n World \n'), + isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('Hello\n World\t\n'), + isNot(hasAGoodToStringDeep)); + // Leading whitespace on line 1. + expect(new _MockToStringDeep(' Hello\n World \n'), + isNot(hasAGoodToStringDeep)); + + // Single line. + expect(new _MockToStringDeep('Hello World'), isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('Hello World\n'), isNot(hasAGoodToStringDeep)); + + expect(new _MockToStringDeep('Hello: World\nFoo: bar\n'), + hasAGoodToStringDeep); + expect(new _MockToStringDeep('Hello: World\nFoo: 42\n'), + hasAGoodToStringDeep); + // Contains default Object.toString(). + expect(new _MockToStringDeep('Hello: World\nFoo: ${new Object()}\n'), + isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('A\n├─B\n'), hasAGoodToStringDeep); + expect(new _MockToStringDeep('A\n├─B\n╘══════\n'), hasAGoodToStringDeep); + // Last line is all whitespace or vertical line art. + expect(new _MockToStringDeep('A\n├─B\n\n'), isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('A\n├─B\n│\n'), isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('A\n├─B\n│\n'), isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('A\n├─B\n│\n'), isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('A\n├─B\n╎\n'), isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('A\n├─B\n║\n'), isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('A\n├─B\n │\n'), isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('A\n├─B\n ╎\n'), isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('A\n├─B\n ║\n'), isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('A\n├─B\n ││\n'), isNot(hasAGoodToStringDeep)); + + expect(new _MockToStringDeep( + 'A\n' + '├─B\n' + '│\n' + '└─C\n'), hasAGoodToStringDeep); + // Last line is all whitespace or vertical line art. + expect(new _MockToStringDeep( + 'A\n' + '├─B\n' + '│\n'), isNot(hasAGoodToStringDeep)); + + expect(new _MockToStringDeep.fromLines( + ['Paragraph#00000\n', + ' │ size: (400x200)\n', + ' ╘═╦══ text ═══\n', + ' ║ TextSpan:\n', + ' ║ "I polished up that handle so carefullee\n', + ' ║ That now I am the Ruler of the Queen\'s Navee!"\n', + ' ╚═══════════\n']), hasAGoodToStringDeep); + + // Text span + expect(new _MockToStringDeep.fromLines( + ['Paragraph#00000\n', + ' │ size: (400x200)\n', + ' ╘═╦══ text ═══\n', + ' ║ TextSpan:\n', + ' ║ "I polished up that handle so carefullee\nThat now I am the Ruler of the Queen\'s Navee!"\n', + ' ╚═══════════\n']), isNot(hasAGoodToStringDeep)); + }); + + test('normalizeHashCodesEquals', () { + expect('Foo#34219', equalsIgnoringHashCodes('Foo#00000')); + expect('Foo#34219', equalsIgnoringHashCodes('Foo#12345')); + expect('Foo#34219', equalsIgnoringHashCodes('Foo#abcdf')); + expect('Foo#34219', isNot(equalsIgnoringHashCodes('Foo'))); + expect('Foo#34219', isNot(equalsIgnoringHashCodes('Foo#'))); + expect('Foo#34219', isNot(equalsIgnoringHashCodes('Foo#0'))); + expect('Foo#34219', isNot(equalsIgnoringHashCodes('Foo#00'))); + expect('Foo#34219', isNot(equalsIgnoringHashCodes('Foo#00000 '))); + expect('Foo#34219', isNot(equalsIgnoringHashCodes('Foo#000000'))); + expect('Foo#34219', isNot(equalsIgnoringHashCodes('Foo#123456'))); + + expect('Foo#34219:', equalsIgnoringHashCodes('Foo#00000:')); + expect('Foo#34219:', isNot(equalsIgnoringHashCodes('Foo#00000'))); + + expect('Foo#a3b4d', equalsIgnoringHashCodes('Foo#00000')); + expect('Foo#a3b4d', equalsIgnoringHashCodes('Foo#12345')); + expect('Foo#a3b4d', equalsIgnoringHashCodes('Foo#abcdf')); + expect('Foo#a3b4d', isNot(equalsIgnoringHashCodes('Foo'))); + expect('Foo#a3b4d', isNot(equalsIgnoringHashCodes('Foo#'))); + expect('Foo#a3b4d', isNot(equalsIgnoringHashCodes('Foo#0'))); + expect('Foo#a3b4d', isNot(equalsIgnoringHashCodes('Foo#00'))); + expect('Foo#a3b4d', isNot(equalsIgnoringHashCodes('Foo#00000 '))); + expect('Foo#a3b4d', isNot(equalsIgnoringHashCodes('Foo#000000'))); + expect('Foo#a3b4d', isNot(equalsIgnoringHashCodes('Foo#123456'))); + + expect('Foo#A3b4D', isNot(equalsIgnoringHashCodes('Foo#00000'))); + + expect('Foo#12345(Bar#9110f)', + equalsIgnoringHashCodes('Foo#00000(Bar#00000)')); + expect('Foo#12345(Bar#9110f)', + isNot(equalsIgnoringHashCodes('Foo#00000(Bar#)'))); + + expect('Foo', isNot(equalsIgnoringHashCodes('Foo#00000'))); + expect('Foo#', isNot(equalsIgnoringHashCodes('Foo#00000'))); + expect('Foo#3421', isNot(equalsIgnoringHashCodes('Foo#00000'))); + expect('Foo#342193', isNot(equalsIgnoringHashCodes('Foo#00000'))); + }); + test('moreOrLessEquals', () { expect(0.0, moreOrLessEquals(1e-11)); expect(1e-11, moreOrLessEquals(0.0));