blob: f3d8d29596d51a9c02e5c8b9606289ad869285c9 [file] [edit]
// Copyright (c) 2022, 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 'dart:math' as math;
import '../../context.dart';
import 'core.dart';
extension StringChecks on Subject<String> {
/// Expects that the value contains [pattern] according to [String.contains];
void contains(Pattern pattern) {
context.expect(() => prefixFirst('contains ', literal(pattern)), (actual) {
if (actual.contains(pattern)) return null;
return Rejection(
which: prefixFirst('Does not contain ', literal(pattern)),
);
});
}
Subject<int> get length => has((m) => m.length, 'length');
void isEmpty() {
context.expect(() => const ['is empty'], (actual) {
if (actual.isEmpty) return null;
return Rejection(which: ['is not empty']);
});
}
void isNotEmpty() {
context.expect(() => const ['is not empty'], (actual) {
if (actual.isNotEmpty) return null;
return Rejection(which: ['is empty']);
});
}
void startsWith(Pattern other) {
context.expect(() => prefixFirst('starts with ', literal(other)), (actual) {
if (actual.startsWith(other)) return null;
return Rejection(
which: prefixFirst('does not start with ', literal(other)),
);
});
}
void endsWith(String other) {
context.expect(() => prefixFirst('ends with ', literal(other)), (actual) {
if (actual.endsWith(other)) return null;
return Rejection(
which: prefixFirst('does not end with ', literal(other)),
);
});
}
/// Expects that the string matches the pattern [expected].
///
/// Fails if [expected] returns an empty result from calling `allMatches` with
/// the value.
///
/// ```
/// check(actual).matchesPattern('abc');
/// check(actual).matchesPattern(RegExp(r'\d'));
/// ```
void matchesPattern(Pattern expected) {
context.expect(() => prefixFirst('matches ', literal(expected)), (actual) {
if (expected.allMatches(actual).isNotEmpty) return null;
return Rejection(
which: prefixFirst('does not match ', literal(expected)),
);
});
}
/// Expects that the `String` contains each of the sub strings in expected
/// in the given order, with any content between them.
///
/// For example, the following will succeed:
///
/// check('abcdefg').containsInOrder(['a','e']);
void containsInOrder(Iterable<String> expected) {
context.expect(
() => prefixFirst('contains, in order: ', literal(expected)),
(actual) {
var fromIndex = 0;
for (var s in expected) {
var index = actual.indexOf(s, fromIndex);
if (index < 0) {
return Rejection(
which: [
...prefixFirst(
'does not have a match for the substring ',
literal(s),
),
if (fromIndex != 0)
'following the other matches up to character $fromIndex',
],
);
}
fromIndex = index + s.length;
}
return null;
},
);
}
/// Expects that the `String` contains exactly the same code units as
/// [expected].
void equals(String expected) {
context.expect(
() => prefixFirst('equals ', literal(expected)),
(actual) => _findDifference(actual, expected),
);
}
/// Expects that the `String` contains the same characters as [expected] if
/// both were lower case.
void equalsIgnoringCase(String expected) {
context.expect(
() => prefixFirst('equals ignoring case ', literal(expected)),
(actual) => _findDifference(
actual.toLowerCase(),
expected.toLowerCase(),
actual,
expected,
),
);
}
/// Expects that the `String` contains the same content as [expected],
/// ignoring differences in whitsepace.
///
/// All runs of whitespace characters are collapsed to a single space, and
/// leading and traiilng whitespace are removed before comparison.
///
/// For example the following will succeed:
///
/// check(' hello world ').equalsIgnoringWhitespace('hello world');
///
/// While the following will fail:
///
/// check('helloworld').equalsIgnoringWhitespace('hello world');
/// check('he llo world').equalsIgnoringWhitespace('hello world');
void equalsIgnoringWhitespace(String expected) {
context.expect(
() => prefixFirst('equals ignoring whitespace ', literal(expected)),
(actual) {
final collapsedActual = _collapseWhitespace(actual);
final collapsedExpected = _collapseWhitespace(expected);
return _findDifference(
collapsedActual,
collapsedExpected,
collapsedActual,
collapsedExpected,
);
},
);
}
}
Rejection? _findDifference(
String actual,
String expected, [
String? actualDisplay,
String? expectedDisplay,
]) {
if (actual == expected) return null;
final escapedActual = escape(actual);
final escapedExpected = escape(expected);
final escapedActualDisplay = actualDisplay != null
? escape(actualDisplay)
: escapedActual;
final escapedExpectedDisplay = expectedDisplay != null
? escape(expectedDisplay)
: escapedExpected;
final minLength = math.min(escapedActual.length, escapedExpected.length);
var i = 0;
for (; i < minLength; i++) {
if (escapedActual.codeUnitAt(i) != escapedExpected.codeUnitAt(i)) {
break;
}
}
if (i == minLength) {
if (escapedExpected.length < escapedActual.length) {
if (expected.isEmpty) {
return Rejection(which: ['is not the empty string']);
}
return Rejection(
which: [
'is too long with unexpected trailing characters:',
_trailing(escapedActualDisplay, i),
],
);
} else {
if (actual.isEmpty) {
return Rejection(
actual: ['an empty string'],
which: [
'is missing all expected characters:',
_trailing(escapedExpectedDisplay, 0),
],
);
}
return Rejection(
which: [
'is too short with missing trailing characters:',
_trailing(escapedExpectedDisplay, i),
],
);
}
} else {
final indentation = ' ' * (i > 10 ? 14 : i);
return Rejection(
which: [
'differs at offset $i:',
'${_leading(escapedExpectedDisplay, i)}'
'${_trailing(escapedExpectedDisplay, i)}',
'${_leading(escapedActualDisplay, i)}'
'${_trailing(escapedActualDisplay, i)}',
'$indentation^',
],
);
}
}
/// The truncated beginning of [s] up to the [end] character.
String _leading(String s, int end) =>
(end > 10) ? '... ${s.substring(end - 10, end)}' : s.substring(0, end);
/// The truncated remainder of [s] starting at the [start] character.
String _trailing(String s, int start) => (start + 10 > s.length)
? s.substring(start)
: '${s.substring(start, start + 10)} ...';
/// Utility function to collapse whitespace runs to single spaces
/// and strip leading/trailing whitespace.
String _collapseWhitespace(String string) {
var result = StringBuffer();
var skipSpace = true;
for (var i = 0; i < string.length; i++) {
var character = string[i];
if (_isWhitespace(character)) {
if (!skipSpace) {
result.write(' ');
skipSpace = true;
}
} else {
result.write(character);
skipSpace = false;
}
}
return result.toString().trim();
}
bool _isWhitespace(String ch) =>
ch == ' ' || ch == '\n' || ch == '\r' || ch == '\t';
extension PatternChecks on Subject<Pattern> {
/// Extracts the matches found by [allMatches] for further expectations.
Subject<Iterable<Match>> hasAllMatchesFor(String input, [int start = 0]) {
return context.nest(() {
final label = literal(input);
return prefixFirst(
'has all matches for ',
start == 0 ? label : postfixLast(' starting at index $start', label),
);
}, (actual) => Extracted.value(actual.allMatches(input, start)));
}
/// Extracts the prefix match found by [matchAsPrefix] for further
/// expectations.
///
/// Expects that [input] matches this pattern as a prefix.
Subject<Match> hasPrefixMatchFor(String input, [int start = 0]) {
return context.nest(
() {
final label = literal(input);
return prefixFirst(
'has prefix match for ',
start == 0 ? label : postfixLast(' starting at index $start', label),
);
},
(actual) {
final match = actual.matchAsPrefix(input, start);
if (match == null) {
return Extracted.rejection(which: ['did not match as prefix']);
}
return Extracted.value(match);
},
);
}
}
extension RegExpChecks on Subject<RegExp> {
/// Extracts the [isCaseSensitive] property for further expectations.
Subject<bool> get isCaseSensitive =>
has((s) => s.isCaseSensitive, 'isCaseSensitive');
/// Extracts the [isDotAll] property for further expectations.
Subject<bool> get isDotAll => has((s) => s.isDotAll, 'isDotAll');
/// Extracts the [isMultiLine] property for further expectations.
Subject<bool> get isMultiLine => has((s) => s.isMultiLine, 'isMultiLine');
/// Extracts the [isUnicode] property for further expectations.
Subject<bool> get isUnicode => has((s) => s.isUnicode, 'isUnicode');
/// Extracts the [pattern] property for further expectations.
Subject<String> get pattern => has((s) => s.pattern, 'pattern');
/// Extracts the first match found by [firstMatch] for further expectations.
///
/// Expects that [input] matches this regular expression.
Subject<Match> hasFirstMatchFor(String input) {
return context.nest(
() => prefixFirst('has first match for ', literal(input)),
(actual) {
final match = actual.firstMatch(input);
if (match == null) {
return Extracted.rejection(which: ['did not match']);
}
return Extracted.value(match);
},
);
}
/// Extracts the string match found by [stringMatch] for further expectations.
///
/// Expects that [input] matches this regular expression.
Subject<String> hasStringMatchFor(String input) {
return context.nest(
() => prefixFirst('has string match for ', literal(input)),
(actual) {
final match = actual.stringMatch(input);
if (match == null) {
return Extracted.rejection(which: ['did not match']);
}
return Extracted.value(match);
},
);
}
/// Expects that this regular expression [hasMatch] for [input].
void hasMatchFor(String input) {
context.expect(() => prefixFirst('has match for ', literal(input)), (
actual,
) {
if (actual.hasMatch(input)) return null;
return Rejection(which: ['did not match']);
});
}
/// Expects that this regular expression does not have a match for [input]
/// according to [hasMatch].
void hasNoMatchFor(String input) {
context.expect(() => prefixFirst('has no match for ', literal(input)), (
actual,
) {
if (!actual.hasMatch(input)) return null;
return Rejection(which: ['matched']);
});
}
}
extension MatchChecks on Subject<Match> {
/// Extracts the [end] property for further expectations.
Subject<int> get end => has((m) => m.end, 'end');
/// Extracts the [groupCount] property for further expectations.
Subject<int> get groupCount => has((m) => m.groupCount, 'groupCount');
/// Extracts the [input] property for further expectations.
Subject<String> get input => has((m) => m.input, 'input');
/// Extracts the [pattern] property for further expectations.
Subject<Pattern> get pattern => has((m) => m.pattern, 'pattern');
/// Extracts the [start] property for further expectations.
Subject<int> get start => has((m) => m.start, 'start');
/// Extracts the group at [index] found by [group] for further expectations.
Subject<String?> hasGroup(int index) =>
has((m) => m.group(index), 'group $index');
/// Extracts the groups at [indices] found by [groups] for further
/// expectations.
Subject<List<String?>> hasGroups(List<int> indices) {
return context.nest(() => prefixFirst('has groups ', literal(indices)), (
actual,
) {
try {
return Extracted.value(actual.groups(indices));
} catch (e) {
return Extracted.rejection(
which: prefixFirst('threw while trying to read groups: ', literal(e)),
);
}
});
}
}
extension RegExpMatchChecks on Subject<RegExpMatch> {
/// Extracts the [groupNames] property for further expectations.
Subject<Iterable<String>> get groupNames =>
has((m) => m.groupNames, 'groupNames');
/// Extracts the [pattern] property for further expectations.
Subject<RegExp> get pattern => has((m) => m.pattern, 'pattern');
/// Extracts the named group [name] found by [namedGroup] for further
/// expectations.
Subject<String?> hasNamedGroup(String name) =>
has((m) => m.namedGroup(name), 'named group "$name"');
}