| // Copyright 2013 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| /// Provides utilities for testing engine code. |
| library matchers; |
| |
| import 'dart:math' as math; |
| |
| import 'package:html/dom.dart' as html_package; |
| import 'package:html/parser.dart' as html_package; |
| |
| import 'package:test/test.dart'; |
| |
| import 'package:ui/src/engine.dart'; |
| import 'package:ui/ui.dart'; |
| |
| /// Enumerates all persisted surfaces in the tree rooted at [root]. |
| /// |
| /// If [root] is `null` returns all surfaces from the last rendered scene. |
| /// |
| /// Surfaces are returned in a depth-first order. |
| Iterable<PersistedSurface> enumerateSurfaces([PersistedSurface? root]) { |
| root ??= SurfaceSceneBuilder.debugLastFrameScene; |
| final List<PersistedSurface> surfaces = <PersistedSurface>[root!]; |
| |
| root.visitChildren((PersistedSurface surface) { |
| surfaces.addAll(enumerateSurfaces(surface)); |
| }); |
| |
| return surfaces; |
| } |
| |
| /// Enumerates all pictures nested under [root]. |
| /// |
| /// If [root] is `null` returns all pictures from the last rendered scene. |
| Iterable<PersistedPicture> enumeratePictures([PersistedSurface? root]) { |
| root ??= SurfaceSceneBuilder.debugLastFrameScene; |
| return enumerateSurfaces(root).whereType<PersistedPicture>(); |
| } |
| |
| /// Enumerates all offset surfaces nested under [root]. |
| /// |
| /// If [root] is `null` returns all pictures from the last rendered scene. |
| Iterable<PersistedOffset> enumerateOffsets([PersistedSurface? root]) { |
| root ??= SurfaceSceneBuilder.debugLastFrameScene; |
| return enumerateSurfaces(root).whereType<PersistedOffset>(); |
| } |
| |
| /// Computes the distance between two values. |
| /// |
| /// The distance should be a metric in a metric space (see |
| /// https://en.wikipedia.org/wiki/Metric_space). Specifically, if `f` is a |
| /// distance function then the following conditions should hold: |
| /// |
| /// - f(a, b) >= 0 |
| /// - f(a, b) == 0 if and only if a == b |
| /// - f(a, b) == f(b, a) |
| /// - f(a, c) <= f(a, b) + f(b, c), known as triangle inequality |
| /// |
| /// This makes it useful for comparing numbers, [Color]s, [Offset]s and other |
| /// sets of value for which a metric space is defined. |
| typedef DistanceFunction<T> = num Function(T a, T b); |
| |
| /// The type of a union of instances of [DistanceFunction<T>] for various types |
| /// T. |
| /// |
| /// This type is used to describe a collection of [DistanceFunction<T>] |
| /// functions which have (potentially) unrelated argument types. Since the |
| /// argument types of the functions may be unrelated, the only thing that the |
| /// type system can statically assume about them is that they accept null (since |
| /// all types in Dart are nullable). |
| /// |
| /// Calling an instance of this type must either be done dynamically, or by |
| /// first casting it to a [DistanceFunction<T>] for some concrete T. |
| typedef AnyDistanceFunction = num Function(Never a, Never b); |
| |
| const Map<Type, AnyDistanceFunction> _kStandardDistanceFunctions = |
| <Type, AnyDistanceFunction>{ |
| Color: _maxComponentColorDistance, |
| Offset: _offsetDistance, |
| int: _intDistance, |
| double: _doubleDistance, |
| Rect: _rectDistance, |
| Size: _sizeDistance, |
| }; |
| |
| int _intDistance(int a, int b) => (b - a).abs(); |
| double _doubleDistance(double a, double b) => (b - a).abs(); |
| double _offsetDistance(Offset a, Offset b) => (b - a).distance; |
| |
| double _maxComponentColorDistance(Color a, Color b) { |
| int delta = math.max<int>((a.red - b.red).abs(), (a.green - b.green).abs()); |
| delta = math.max<int>(delta, (a.blue - b.blue).abs()); |
| delta = math.max<int>(delta, (a.alpha - b.alpha).abs()); |
| return delta.toDouble(); |
| } |
| |
| double _rectDistance(Rect a, Rect b) { |
| double delta = |
| math.max<double>((a.left - b.left).abs(), (a.top - b.top).abs()); |
| delta = math.max<double>(delta, (a.right - b.right).abs()); |
| delta = math.max<double>(delta, (a.bottom - b.bottom).abs()); |
| return delta; |
| } |
| |
| double _sizeDistance(Size a, Size b) { |
| final Offset delta = (b - a) as Offset; // ignore: unnecessary_parenthesis |
| return delta.distance; |
| } |
| |
| /// Asserts that two values are within a certain distance from each other. |
| /// |
| /// The distance is computed by a [DistanceFunction]. |
| /// |
| /// If `distanceFunction` is null, a standard distance function is used for the |
| /// type `T` . Standard functions are defined for the following types: |
| /// |
| /// * [Color], whose distance is the maximum component-wise delta. |
| /// * [Offset], whose distance is the Euclidean distance computed using the |
| /// method [Offset.distance]. |
| /// * [Rect], whose distance is the maximum component-wise delta. |
| /// * [Size], whose distance is the [Offset.distance] of the offset computed as |
| /// the difference between two sizes. |
| /// * [int], whose distance is the absolute difference between two integers. |
| /// * [double], whose distance is the absolute difference between two doubles. |
| /// |
| /// See also: |
| /// |
| /// * [moreOrLessEquals], which is similar to this function, but specializes in |
| /// [double]s and has an optional `epsilon` parameter. |
| /// * [closeTo], which specializes in numbers only. |
| Matcher within<T>({ |
| required num distance, |
| required T from, |
| DistanceFunction<T>? distanceFunction, |
| }) { |
| distanceFunction ??= _kStandardDistanceFunctions[T] as DistanceFunction<T>?; |
| |
| if (distanceFunction == null) { |
| throw ArgumentError( |
| 'The specified distanceFunction was null, and a standard distance ' |
| 'function was not found for type $T of the provided ' |
| '`from` argument.'); |
| } |
| |
| return _IsWithinDistance<T>(distanceFunction, from, distance); |
| } |
| |
| class _IsWithinDistance<T> extends Matcher { |
| const _IsWithinDistance(this.distanceFunction, this.value, this.epsilon); |
| |
| final DistanceFunction<T> distanceFunction; |
| final T value; |
| final num epsilon; |
| |
| @override |
| bool matches(Object? object, Map<dynamic, dynamic> matchState) { |
| if (object is! T) { |
| return false; |
| } |
| if (object == value) { |
| return true; |
| } |
| final T test = object; |
| final num distance = distanceFunction(test, value); |
| if (distance < 0) { |
| throw ArgumentError( |
| 'Invalid distance function was used to compare a ${value.runtimeType} ' |
| 'to a ${object.runtimeType}. The function must return a non-negative ' |
| 'double value, but it returned $distance.'); |
| } |
| matchState['distance'] = distance; |
| return distance <= epsilon; |
| } |
| |
| @override |
| Description describe(Description description) => |
| description.add('$value (±$epsilon)'); |
| |
| @override |
| Description describeMismatch( |
| Object? object, |
| Description mismatchDescription, |
| Map<dynamic, dynamic> matchState, |
| bool verbose, |
| ) { |
| mismatchDescription |
| .add('was ${matchState['distance']} away from the desired value.'); |
| return mismatchDescription; |
| } |
| } |
| |
| /// Controls how test HTML is canonicalized by [canonicalizeHtml] function. |
| /// |
| /// In all cases whitespace between elements is stripped. |
| enum HtmlComparisonMode { |
| /// Retains all attributes. |
| /// |
| /// Useful when very precise HTML comparison is needed that includes both |
| /// layout and non-layout style attributes. This mode is rarely needed. Most |
| /// tests should use [layoutOnly] or [nonLayoutOnly]. |
| everything, |
| |
| /// Retains only layout style attributes, such as "width". |
| /// |
| /// Useful when testing layout because it filters out all the noise that does |
| /// not affect layout. |
| layoutOnly, |
| |
| /// Retains only non-layout style attributes, such as "color". |
| /// |
| /// Useful when testing styling because it filters out all the noise from the |
| /// layout attributes. |
| nonLayoutOnly, |
| |
| /// Do not consider attributes when comparing HTML. |
| noAttributes, |
| } |
| |
| /// Rewrites [htmlContent] by removing irrelevant style attributes. |
| /// |
| /// If [throwOnUnusedAttributes] is `true`, throws instead of rewriting. Set |
| /// [throwOnUnusedAttributes] to `true` to check that expected HTML strings do |
| /// not contain irrelevant attributes. It is ok for actual HTML to contain all |
| /// kinds of attributes. They only need to be filtered out before testing. |
| String canonicalizeHtml( |
| String htmlContent, { |
| HtmlComparisonMode mode = HtmlComparisonMode.nonLayoutOnly, |
| bool throwOnUnusedAttributes = false, |
| List<String>? ignoredAttributes, |
| }) { |
| if (htmlContent.trim().isEmpty) { |
| return ''; |
| } |
| |
| String? unusedAttribute(String name) { |
| if (throwOnUnusedAttributes) { |
| fail('Provided HTML contains style attribute "$name" which ' |
| 'is not used for comparison in the test. The HTML was:\n\n$htmlContent'); |
| } |
| |
| return null; |
| } |
| |
| html_package.Element cleanup(html_package.Element original) { |
| String replacementTag = original.localName!; |
| switch (replacementTag) { |
| case 'flt-scene': |
| replacementTag = 's'; |
| break; |
| case 'flt-transform': |
| replacementTag = 't'; |
| break; |
| case 'flt-opacity': |
| replacementTag = 'o'; |
| break; |
| case 'flt-clip': |
| final String? clipType = original.attributes['clip-type']; |
| switch (clipType) { |
| case 'rect': |
| replacementTag = 'clip'; |
| break; |
| case 'rrect': |
| replacementTag = 'rclip'; |
| break; |
| case 'physical-shape': |
| replacementTag = 'pshape'; |
| break; |
| default: |
| throw Exception('Unknown clip type: $clipType'); |
| } |
| break; |
| case 'flt-clip-interior': |
| replacementTag = 'clip-i'; |
| break; |
| case 'flt-picture': |
| replacementTag = 'pic'; |
| break; |
| case 'flt-canvas': |
| replacementTag = 'c'; |
| break; |
| case 'flt-dom-canvas': |
| replacementTag = 'd'; |
| break; |
| case 'flt-semantics': |
| replacementTag = 'sem'; |
| break; |
| case 'flt-semantics-container': |
| replacementTag = 'sem-c'; |
| break; |
| case 'flt-semantics-img': |
| replacementTag = 'sem-img'; |
| break; |
| case 'flt-semantics-text-field': |
| replacementTag = 'sem-tf'; |
| break; |
| } |
| |
| final html_package.Element replacement = |
| html_package.Element.tag(replacementTag); |
| |
| if (mode != HtmlComparisonMode.noAttributes) { |
| original.attributes.forEach((dynamic name, String value) { |
| if (name is! String) { |
| throw ArgumentError('"$name" should be String but was ${name.runtimeType}.'); |
| } |
| if (name == 'style') { |
| return; |
| } |
| if (name.startsWith('aria-')) { |
| replacement.attributes[name] = value; |
| } |
| }); |
| |
| if (original.attributes.containsKey('style')) { |
| final String styleValue = original.attributes['style']!; |
| |
| int attrCount = 0; |
| final String processedAttributes = styleValue |
| .split(';') |
| .map((String attr) { |
| attr = attr.trim(); |
| if (attr.isEmpty) { |
| return null; |
| } |
| |
| if (mode != HtmlComparisonMode.everything) { |
| final bool forLayout = mode == HtmlComparisonMode.layoutOnly; |
| final List<String> parts = attr.split(':'); |
| if (parts.length == 2) { |
| final String name = parts.first; |
| |
| if (ignoredAttributes != null && ignoredAttributes.contains(name)) { |
| return null; |
| } |
| |
| // Whether the attribute is one that's set to the same value and |
| // never changes. Such attributes are usually not interesting to |
| // test. |
| final bool isStaticAttribute = const <String>[ |
| 'top', |
| 'left', |
| 'position', |
| ].contains(name); |
| |
| if (isStaticAttribute) { |
| return unusedAttribute(name); |
| } |
| |
| // Whether the attribute is set by the layout system. |
| final bool isLayoutAttribute = const <String>[ |
| 'top', |
| 'left', |
| 'bottom', |
| 'right', |
| 'position', |
| 'width', |
| 'height', |
| 'font-size', |
| 'transform', |
| 'transform-origin', |
| 'white-space', |
| ].contains(name); |
| |
| if (forLayout && !isLayoutAttribute || |
| !forLayout && isLayoutAttribute) { |
| return unusedAttribute(name); |
| } |
| } |
| } |
| |
| attrCount++; |
| return attr.trim(); |
| }) |
| .where((String? attr) => attr != null && attr.isNotEmpty) |
| .join('; '); |
| |
| if (attrCount > 0) { |
| replacement.attributes['style'] = processedAttributes; |
| } |
| } |
| } else if (throwOnUnusedAttributes && original.attributes.isNotEmpty) { |
| fail('Provided HTML contains attributes. However, the comparison mode ' |
| 'is $mode. The HTML was:\n\n$htmlContent'); |
| } |
| |
| for (final html_package.Node child in original.nodes) { |
| if (child is html_package.Text && child.text.trim().isEmpty) { |
| continue; |
| } |
| |
| if (child is html_package.Element) { |
| replacement.append(cleanup(child)); |
| } else { |
| replacement.append(child.clone(true)); |
| } |
| } |
| |
| return replacement; |
| } |
| |
| final html_package.DocumentFragment originalDom = |
| html_package.parseFragment(htmlContent); |
| |
| final html_package.DocumentFragment cleanDom = |
| html_package.DocumentFragment(); |
| for (final html_package.Element child in originalDom.children) { |
| cleanDom.append(cleanup(child)); |
| } |
| |
| return cleanDom.outerHtml; |
| } |
| |
| /// Tests that [element] has the HTML structure described by [expectedHtml]. |
| void expectHtml(DomElement element, String expectedHtml, |
| {HtmlComparisonMode mode = HtmlComparisonMode.nonLayoutOnly}) { |
| expectedHtml = |
| canonicalizeHtml(expectedHtml, mode: mode, throwOnUnusedAttributes: true); |
| final String actualHtml = canonicalizeHtml(element.outerHTML!, mode: mode); |
| expect(actualHtml, expectedHtml); |
| } |
| |
| /// Tests that [currentHtml] matches [expectedHtml]. |
| /// |
| /// The comparison does not consider every minutia of the DOM. By default it |
| /// tests the element tree structure and non-layout style attributes, and |
| /// ignores everything else. If you are testing layout specifically, pass the |
| /// [HtmlComparisonMode.layoutOnly] as the [mode] argument. |
| /// |
| /// To keep test HTML strings manageable, you may use short HTML tag names |
| /// instead of the full names: |
| /// |
| /// * <flt-scene> is interchangeable with <s> |
| /// * <flt-transform> is interchangeable with <t> |
| /// * <flt-opacity> is interchangeable with <o> |
| /// * <flt-clip clip-type="rect"> is interchangeable with <clip> |
| /// * <flt-clip clip-type="rrect"> is interchangeable with <rclip> |
| /// * <flt-clip clip-type="physical-shape"> is interchangeable with <pshape> |
| /// * <flt-picture> is interchangeable with <pic> |
| /// * <flt-canvas> is interchangeable with <c> |
| /// |
| /// To simplify test HTML strings further the elements corresponding to the |
| /// root view [RenderView], such as <flt-scene> (i.e. <s>), are also stripped |
| /// out before comparison. |
| /// |
| /// Example: |
| /// |
| /// If you call [WidgetTester.pumpWidget] that results in HTML |
| /// `<s><t><pic><c><p>Hello</p></c></pic></t></s>`, you don't have to specify |
| /// `<s><t>` tags and simply expect `<pic><c><p>Hello</p></c></pic>`. |
| void expectPageHtml(String expectedHtml, |
| {HtmlComparisonMode mode = HtmlComparisonMode.nonLayoutOnly}) { |
| expectedHtml = canonicalizeHtml(expectedHtml, mode: mode); |
| final String actualHtml = canonicalizeHtml(currentHtml, mode: mode); |
| expect(actualHtml, expectedHtml); |
| } |
| |
| /// Currently rendered HTML DOM as an HTML string. |
| String get currentHtml { |
| return flutterViewEmbedder.sceneElement?.outerHTML ?? ''; |
| } |
| |
| class SceneTester { |
| SceneTester(this.scene); |
| |
| final SurfaceScene scene; |
| |
| void expectSceneHtml(String expectedHtml) { |
| expectHtml(scene.webOnlyRootElement!, expectedHtml, |
| mode: HtmlComparisonMode.noAttributes); |
| } |
| } |
| |
| /// A matcher for functions that throw [AssertionError]. |
| /// |
| /// This is equivalent to `throwsA(isInstanceOf<AssertionError>())`. |
| /// |
| /// If you are trying to test whether a call to [WidgetTester.pumpWidget] |
| /// results in an [AssertionError], see |
| /// [TestWidgetsFlutterBinding.takeException]. |
| /// |
| /// See also: |
| /// |
| /// * [throwsFlutterError], to test if a function throws a [FlutterError]. |
| /// * [throwsArgumentError], to test if a functions throws an [ArgumentError]. |
| /// * [isAssertionError], to test if any object is any kind of [AssertionError]. |
| final Matcher throwsAssertionError = throwsA(isAssertionError); |
| |
| /// A matcher for [AssertionError]. |
| /// |
| /// This is equivalent to `isInstanceOf<AssertionError>()`. |
| /// |
| /// See also: |
| /// |
| /// * [throwsAssertionError], to test if a function throws any [AssertionError]. |
| /// * [isFlutterError], to test if any object is a [FlutterError]. |
| const Matcher isAssertionError = TypeMatcher<AssertionError>(); |