From d3892b28811d5b5daad272a7f484b77f34c91418 Mon Sep 17 00:00:00 2001 From: Pascal Welsch Date: Mon, 8 Jan 2024 04:37:09 +0100 Subject: [PATCH] Implement negate --- lib/src/spot/selectors.dart | 111 +++++++++++++++++++++++++++++++----- lib/src/spot/snapshot.dart | 46 ++++++++------- test/spot/negate_test.dart | 56 ++++++++++++++++++ 3 files changed, 178 insertions(+), 35 deletions(-) create mode 100644 test/spot/negate_test.dart diff --git a/lib/src/spot/selectors.dart b/lib/src/spot/selectors.dart index 9f653973..5bdb20bf 100644 --- a/lib/src/spot/selectors.dart +++ b/lib/src/spot/selectors.dart @@ -705,11 +705,16 @@ extension WidgetMatcherExtensions on WidgetMatcher { .getProperties() .firstOrNullWhere((e) => e.name == propName); + final unconstrainedSelector = + selector.overrideQuantityConstraint(QuantityConstraint.unconstrained); final actual = prop?.value as T? ?? prop?.getDefaultValue(); final ConditionSubject conditionSubject = it(); final Subject subject = conditionSubject.context.nest( - () => [selector.toStringBreadcrumb(), 'with property $propName'], + () => [ + unconstrainedSelector.toStringBreadcrumb(), + 'with property $propName', + ], (value) { if (prop == null) { return Extracted.rejection(which: ['Has no prop "$propName"']); @@ -920,8 +925,20 @@ class PredicateWithDescription { } } -class WidgetTypePredicate extends PredicateWithDescription { - WidgetTypePredicate() : super((e) => e.widget is W, description: '$W'); +class WidgetTypePredicate + implements PredicateWithDescription { + WidgetTypePredicate(); + + @override + String get description => '$W'; + + @override + bool Function(Element e) get predicate => (e) => e.widget is W; + + @override + String toString() { + return 'WidgetTypePredicate<$W>()'; + } } /// A [WidgetSelector] that intends to resolve to a single widget @@ -1005,6 +1022,17 @@ class ChildFilter implements ElementFilter { @override Iterable filter(Iterable candidates) { final tree = currentWidgetTreeSnapshot(); + + // First check all negate selectors (where maxQuantity == 0) + final negates = childSelectors.where((e) => e.quantityConstraint.max == 0); + for (final negate in negates) { + final s = snapshot(negate, validateQuantity: false); + if (s.discovered.isNotEmpty) { + // this negate selector matches, which it shouldn't + return []; + } + } + final List matchingChildNodes = []; // Then check for every queryMatch if the children and props match @@ -1013,19 +1041,21 @@ class ChildFilter implements ElementFilter { final ScopedWidgetTreeSnapshot subtree = tree.scope(candidate); final List subtreeNodes = subtree.allNodes; - for (final WidgetSelector childSelector in childSelectors) { + for (final WidgetSelector childSelector + in childSelectors - negates) { matchesPerChild[childSelector] = []; // TODO instead of searching the children, starting from the root widget, find a way to reverse the search and // start form the subtree. // Keep in mind, each child selector might be defined with parents which are outside of the subtree final WidgetSnapshot ss = snapshot(childSelector, validateQuantity: false); - final discoveredInSubtree = - ss.discovered.where((node) => subtreeNodes.contains(node)).toList(); final minConstraint = childSelector.quantityConstraint.min; final maxConstraint = childSelector.quantityConstraint.max; + final discoveredInSubtree = + ss.discovered.where((node) => subtreeNodes.contains(node)).toList(); + if (minConstraint == null && maxConstraint == null && discoveredInSubtree.isEmpty) { @@ -1172,9 +1202,31 @@ class WidgetSelector with Selectors { final filters = elementFilters.isNotEmpty ? elementFilters.map((e) => e.description).join(' ') : null; + final quantity = () { + if (quantityConstraint.min == null && quantityConstraint.max == 0) { + return '(amount: 0)'; + } + if (quantityConstraint.min == 0 && quantityConstraint.max == 0) { + return '(amount: 0)'; + } + if (quantityConstraint.min != null && + quantityConstraint.min == quantityConstraint.max) { + return '(amount: ${quantityConstraint.min})'; + } + if (quantityConstraint.min != null && quantityConstraint.max != null) { + return '(amount: ${quantityConstraint.min}...${quantityConstraint.max})'; + } + if (quantityConstraint.min != null) { + return '(amount: >=${quantityConstraint.min})'; + } + if (quantityConstraint.max != null) { + return '(amount: <=${quantityConstraint.max})'; + } + return null; + }(); final constraints = - [props, children, parents, filters].where((e) => e != null); + [props, quantity, children, parents, filters].where((e) => e != null); if (constraints.isEmpty) { return ''; } @@ -1191,8 +1243,31 @@ class WidgetSelector with Selectors { final filters = elementFilters.isNotEmpty ? elementFilters.map((e) => e.description).join(' ') : null; + final quantity = () { + if (quantityConstraint.min == null && quantityConstraint.max == 0) { + return '(amount: 0)'; + } + if (quantityConstraint.min == 0 && quantityConstraint.max == 0) { + return '(amount: 0)'; + } + if (quantityConstraint.min != null && + quantityConstraint.min == quantityConstraint.max) { + return '(amount: ${quantityConstraint.min})'; + } + if (quantityConstraint.min != null && quantityConstraint.max != null) { + return '(amount: ${quantityConstraint.min}...${quantityConstraint.max})'; + } + if (quantityConstraint.min != null) { + return '(amount: >=${quantityConstraint.min})'; + } + if (quantityConstraint.max != null) { + return '(amount: <=${quantityConstraint.max})'; + } + return null; + }(); - final constraints = [props, children, filters].where((e) => e != null); + final constraints = + [props, quantity, children, filters].where((e) => e != null); return constraints.join(' '); } @@ -1326,11 +1401,16 @@ class WidgetSelector with Selectors { .getProperties() .firstOrNullWhere((e) => e.name == propName); + final unconstrainedSelector = + overrideQuantityConstraint(QuantityConstraint.unconstrained); final actual = prop?.value as T? ?? prop?.getDefaultValue(); final ConditionSubject conditionSubject = it(); final Subject subject = conditionSubject.context.nest( - () => [toStringBreadcrumb(), 'with prop "$propName"'], + () => [ + unconstrainedSelector.toStringBreadcrumb(), + 'with prop "$propName"', + ], (value) { if (prop == null) { return Extracted.rejection(which: ['Has no prop "$propName"']); @@ -1369,10 +1449,11 @@ extension CreateWidgetMatcher on WidgetSnapshot { /// The number of widgets that are expected to be found class QuantityConstraint { static const QuantityConstraint unconstrained = QuantityConstraint(); - static const QuantityConstraint none = QuantityConstraint.exactly(0); + static const QuantityConstraint zero = QuantityConstraint.atMost(0); static const QuantityConstraint single = QuantityConstraint.atMost(1); - const QuantityConstraint({this.min, this.max}); + const QuantityConstraint({this.min, this.max}) + : assert(min == null || max == null || min <= max); const QuantityConstraint.exactly(int n) : min = n, @@ -1394,8 +1475,8 @@ class QuantityConstraint { if (min == null && max == null) { return 'QuantityConstraint.unconstrained'; } - if (min == 0 && max == 0) { - return 'QuantityConstraint.none'; + if (max == 0) { + return 'QuantityConstraint.zero'; } return 'QuantityConstraint{min: $min, max: $max}'; } @@ -1420,7 +1501,7 @@ extension SelectorToSnapshot on WidgetSelector { @useResult WidgetSelector get multi { - return overrideQuantityConstraint(QuantityConstraint.none); + return overrideQuantityConstraint(QuantityConstraint.unconstrained); } @Deprecated('Use .atMost(1) or .amount(1)') @@ -1563,7 +1644,7 @@ extension QuantityMatchers on WidgetSelector { void doesNotExist() { TestAsyncUtils.guardSync(); - final none = copyWith(quantityConstraint: QuantityConstraint.none); + final none = copyWith(quantityConstraint: QuantityConstraint.zero); snapshot(none); } diff --git a/lib/src/spot/snapshot.dart b/lib/src/spot/snapshot.dart index 0c7182e3..003ed416 100644 --- a/lib/src/spot/snapshot.dart +++ b/lib/src/spot/snapshot.dart @@ -147,6 +147,9 @@ extension _ValidateQuantity on WidgetSnapshot { final minimumConstraint = selector.quantityConstraint.min; final maximumConstraint = selector.quantityConstraint.max; + final unconstrainedSelector = + selector.overrideQuantityConstraint(QuantityConstraint.unconstrained); + String significantWidgetTree() { final set = discoveredElements.toSet(); return findCommonAncestor(set).toStringDeep(); @@ -156,10 +159,10 @@ extension _ValidateQuantity on WidgetSnapshot { if (count == 0) { _tryMatchingLessSpecificCriteria(this); throw TestFailure( - 'Could not find ${selector.toStringBreadcrumb()} in widget tree, ' + 'Could not find ${unconstrainedSelector.toStringBreadcrumb()} in widget tree, ' 'expected at least $minimumConstraint\n' '${significantWidgetTree()}' - 'Could not find ${selector.toStringBreadcrumb()} in widget tree, ' + 'Could not find ${unconstrainedSelector.toStringBreadcrumb()} in widget tree, ' 'expected at least $minimumConstraint', ); } @@ -167,10 +170,10 @@ extension _ValidateQuantity on WidgetSnapshot { if (minimumConstraint > count) { _tryMatchingLessSpecificCriteria(this); throw TestFailure( - 'Found $count elements matching ${selector.toStringBreadcrumb()} in widget tree, ' + 'Found $count elements matching ${unconstrainedSelector.toStringBreadcrumb()} in widget tree, ' 'expected at least $minimumConstraint\n' '${significantWidgetTree()}' - 'Found $count elements matching ${selector.toStringBreadcrumb()} in widget tree, ' + 'Found $count elements matching ${unconstrainedSelector.toStringBreadcrumb()} in widget tree, ' 'expected at least $minimumConstraint', ); } @@ -179,10 +182,10 @@ extension _ValidateQuantity on WidgetSnapshot { if (maximumConstraint != null && minimumConstraint == null) { if (maximumConstraint < count) { throw TestFailure( - 'Found $count elements matching ${selector.toStringBreadcrumb()} in widget tree, ' + 'Found $count elements matching ${unconstrainedSelector.toStringBreadcrumb()} in widget tree, ' 'expected at most $maximumConstraint\n' '${significantWidgetTree()}' - 'Found $count elements matching ${selector.toStringBreadcrumb()} in widget tree, ' + 'Found $count elements matching ${unconstrainedSelector.toStringBreadcrumb()} in widget tree, ' 'expected at most $maximumConstraint', ); } @@ -198,19 +201,19 @@ extension _ValidateQuantity on WidgetSnapshot { if (count == 0) { _tryMatchingLessSpecificCriteria(this); throw TestFailure( - 'Could not find ${selector.toStringBreadcrumb()} in widget tree, ' + 'Could not find ${unconstrainedSelector.toStringBreadcrumb()} in widget tree, ' 'expected exactly $exactCount\n' '${significantWidgetTree()}' - 'Could not find ${selector.toStringBreadcrumb()} in widget tree, ' + 'Could not find ${unconstrainedSelector.toStringBreadcrumb()} in widget tree, ' 'expected exactly $exactCount', ); } else { _tryMatchingLessSpecificCriteria(this); throw TestFailure( - 'Found $count elements matching ${selector.toStringBreadcrumb()} in widget tree, ' + 'Found $count elements matching ${unconstrainedSelector.toStringBreadcrumb()} in widget tree, ' 'expected exactly $exactCount\n' '${significantWidgetTree()}' - 'Found $count elements matching ${selector.toStringBreadcrumb()} in widget tree, ' + 'Found $count elements matching ${unconstrainedSelector.toStringBreadcrumb()} in widget tree, ' 'expected exactly $exactCount', ); } @@ -218,10 +221,10 @@ extension _ValidateQuantity on WidgetSnapshot { } else { // out of range throw TestFailure( - 'Found $count elements matching ${selector.toStringBreadcrumb()} in widget tree, ' + 'Found $count elements matching ${unconstrainedSelector.toStringBreadcrumb()} in widget tree, ' 'expected between $minimumConstraint and $maximumConstraint\n' '${significantWidgetTree()}' - 'Found $count elements matching ${selector.toStringBreadcrumb()} in widget tree, ' + 'Found $count elements matching ${unconstrainedSelector.toStringBreadcrumb()} in widget tree, ' 'expected between $minimumConstraint and $maximumConstraint', ); } @@ -317,18 +320,21 @@ void _tryMatchingLessSpecificCriteria(WidgetSnapshot snapshot) { final lessSpecificCount = lessSpecificSnapshot.discovered.length; final minimumConstraint = selector.quantityConstraint.min; final maximumConstraint = selector.quantityConstraint.max; + final unconstrainedSelector = + selector.overrideQuantityConstraint(QuantityConstraint.unconstrained); + // error that selector could not be found, but instead spot detected lessSpecificSnapshot, which might be useful if (lessSpecificCount > count) { if (minimumConstraint != null && maximumConstraint == null) { if (count == 0) { errorBuilder.writeln( - 'Could not find ${selector.toStringBreadcrumb()} in widget tree, ' + 'Could not find ${unconstrainedSelector.toStringBreadcrumb()} in widget tree, ' 'expected at least $minimumConstraint'); } if (minimumConstraint > count) { errorBuilder.writeln( - 'Found $count elements matching ${selector.toStringBreadcrumb()} in widget tree, ' + 'Found $count elements matching ${unconstrainedSelector.toStringBreadcrumb()} in widget tree, ' 'expected at least $minimumConstraint'); } } @@ -336,7 +342,7 @@ void _tryMatchingLessSpecificCriteria(WidgetSnapshot snapshot) { if (maximumConstraint != null && minimumConstraint == null) { if (maximumConstraint < count) { errorBuilder.writeln( - 'Found $count elements matching ${selector.toStringBreadcrumb()} in widget tree, ' + 'Found $count elements matching ${unconstrainedSelector.toStringBreadcrumb()} in widget tree, ' 'expected at most $maximumConstraint'); } } @@ -346,17 +352,17 @@ void _tryMatchingLessSpecificCriteria(WidgetSnapshot snapshot) { final exactCount = minimumConstraint; if (count == 0) { errorBuilder.writeln( - 'Could not find ${selector.toStringBreadcrumb()} in widget tree, ' + 'Could not find ${unconstrainedSelector.toStringBreadcrumb()} in widget tree, ' 'expected exactly $exactCount'); } else { errorBuilder.writeln( - 'Found $count elements matching ${selector.toStringBreadcrumb()} in widget tree, ' + 'Found $count elements matching ${unconstrainedSelector.toStringBreadcrumb()} in widget tree, ' 'expected exactly $exactCount'); } } else { // out of range errorBuilder.writeln( - 'Found $count elements matching ${selector.toStringBreadcrumb()} in widget tree, ' + 'Found $count elements matching ${unconstrainedSelector.toStringBreadcrumb()} in widget tree, ' 'expected between $minimumConstraint and $maximumConstraint'); } } @@ -365,7 +371,7 @@ void _tryMatchingLessSpecificCriteria(WidgetSnapshot snapshot) { "A less specific search ($lessSpecificSelector) discovered $lessSpecificCount matches!", ); errorBuilder.writeln( - 'Maybe you have to adjust your WidgetSelector ($selector) to cover those missing elements.\n', + 'Maybe you have to adjust your WidgetSelector ($unconstrainedSelector) to cover those missing elements.\n', ); int index = 0; for (final Element match in lessSpecificSnapshot.discoveredElements) { @@ -454,7 +460,7 @@ extension _LessSpecificSelectors on WidgetSelector { props: [], parents: [], children: [], - quantityConstraint: QuantityConstraint.none, + quantityConstraint: QuantityConstraint.unconstrained, ); for (final criteria in criteria) { s = criteria(s); diff --git a/test/spot/negate_test.dart b/test/spot/negate_test.dart new file mode 100644 index 00000000..dce00985 --- /dev/null +++ b/test/spot/negate_test.dart @@ -0,0 +1,56 @@ +// ignore_for_file: unnecessary_const, prefer_const_constructors, prefer_const_literals_to_create_immutables + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spot/spot.dart'; + +import '../util/assert_error.dart'; + +void main() { + testWidgets('doesNotExist() checks for non-existence', (tester) async { + await tester.pumpWidget(MaterialApp()); + spot().doesNotExist(); + }); + + testWidgets('negate child', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView(), + ), + ), + ); + spot().existsOnce(); + spot().withChild(spot()).existsOnce(); + + // check that Scaffold does not have a child of type ListView + spot().withChild(spot().atMost(0)).doesNotExist(); + + // check that Scaffold does not have a child of type Placeholder + spot().withChild(spot().atMost(0)).existsOnce(); + }); + + testWidgets('fail due to negate', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView(), + ), + ), + ); + + expect( + () => spot().withChild(spot().atMost(0)).existsOnce(), + throwsSpotErrorContaining( + [ + 'Could not find Scaffold with children: [ListView (amount: 0)] in widget tree, expected exactly 1', + ], + ), + ); + expect( + () => + spot().withChild(spot().atMost(0)).doesNotExist(), + returnsNormally, + ); + }); +}