// Copyright 2019 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. /// Provides utilities for testing engine code. library matchers; import 'dart:html' as html; import 'dart:math' as math; import 'package:html/parser.dart' as html_package; import 'package:html/dom.dart' as html_package; import 'package:meta/meta.dart'; import 'package:test/test.dart'; import 'package:flutter_web_ui/ui.dart'; import 'package:flutter_web_ui/src/engine.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 enumerateSurfaces([PersistedSurface root]) { root ??= SceneBuilder.debugLastFrameScene; final List surfaces = [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 enumeratePictures([PersistedSurface root]) { root ??= SceneBuilder.debugLastFrameScene; return enumerateSurfaces(root).whereType(); } /// Enumerates all offset surfaces nested under [root]. /// /// If [root] is `null` returns all pictures from the last rendered scene. Iterable enumerateOffsets([PersistedSurface root]) { root ??= SceneBuilder.debugLastFrameScene; return enumerateSurfaces(root).whereType(); } /// 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 = num Function(T a, T b); /// The type of a union of instances of [DistanceFunction] for various types /// T. /// /// This type is used to describe a collection of [DistanceFunction] /// 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] for some concrete T. typedef AnyDistanceFunction = num Function(Null a, Null b); const Map _kStandardDistanceFunctions = { 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((a.red - b.red).abs(), (a.green - b.green).abs()); delta = math.max(delta, (a.blue - b.blue).abs()); delta = math.max(delta, (a.alpha - b.alpha).abs()); return delta.toDouble(); } double _rectDistance(Rect a, Rect b) { double delta = math.max((a.left - b.left).abs(), (a.top - b.top).abs()); delta = math.max(delta, (a.right - b.right).abs()); delta = math.max(delta, (a.bottom - b.bottom).abs()); return delta; } double _sizeDistance(Size a, Size b) { final Offset delta = b - a; 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 /// `runtimeType` of the `from` argument. 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({ @required num distance, @required T from, DistanceFunction distanceFunction, }) { distanceFunction ??= _kStandardDistanceFunctions[from.runtimeType]; if (distanceFunction == null) { throw ArgumentError( 'The specified distanceFunction was null, and a standard distance ' 'function was not found for type ${from.runtimeType} of the provided ' '`from` argument.'); } return _IsWithinDistance(distanceFunction, from, distance); } class _IsWithinDistance extends Matcher { const _IsWithinDistance(this.distanceFunction, this.value, this.epsilon); final DistanceFunction distanceFunction; final T value; final num epsilon; @override bool matches(Object object, Map 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 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}) { if (htmlContent == null || 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-value': replacementTag = 'sem-v'; 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 == '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 parts = attr.split(':'); if (parts.length == 2) { final String name = parts.first; // 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 [ 'top', 'left', 'position', ].contains(name); if (isStaticAttribute) { return _unusedAttribute(name); } // Whether the attribute is set by the layout system. final bool isLayoutAttribute = const [ '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 (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 (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(html.Element 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: /// /// * is interchangeable with /// * is interchangeable with /// * is interchangeable with /// * is interchangeable with /// * is interchangeable with /// * is interchangeable with /// * is interchangeable with /// * is interchangeable with /// /// To simplify test HTML strings further the elements corresponding to the /// root view [RenderView], such as (i.e. ), are also stripped /// out before comparison. /// /// Example: /// /// If you call [WidgetTester.pumpWidget] that results in HTML /// `

Hello

`, you don't have to specify /// `` tags and simply expect `

Hello

`. 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 domRenderer.sceneElement?.outerHtml ?? ''; } class SceneTester { SceneTester(this.scene); final Scene scene; void expectSceneHtml(String expectedHtml) { expectHtml(scene.webOnlyRootElement, expectedHtml, mode: HtmlComparisonMode.noAttributes); } }