library matchers;
import 'dart:html' as html;
import 'package:html/parser.dart' as html_package;
import 'package:html/dom.dart' as html_package;
import 'dart:math' as math;
import 'package:meta/meta.dart';
import 'package:test/test.dart';
import 'package:ui/ui.dart';
import 'package:ui/src/engine.dart';
Iterable<PersistedSurface> enumerateSurfaces([PersistedSurface root]) {
root ??= SceneBuilder.debugLastFrameScene;
final List<PersistedSurface> surfaces = <PersistedSurface>[root];
root.visitChildren((PersistedSurface surface) {
surfaces.addAll(enumerateSurfaces(surface));
});
return surfaces;
}
Iterable<PersistedPicture> enumeratePictures([PersistedSurface root]) {
root ??= SceneBuilder.debugLastFrameScene;
return enumerateSurfaces(root).whereType<PersistedPicture>();
}
Iterable<PersistedOffset> enumerateOffsets([PersistedSurface root]) {
root ??= SceneBuilder.debugLastFrameScene;
return enumerateSurfaces(root).whereType<PersistedOffset>();
}
typedef DistanceFunction<T> = num Function(T a, T b);
typedef AnyDistanceFunction = num Function(Null a, Null 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;
return delta.distance;
}
Matcher within<T>({
@required num distance,
@required T from,
DistanceFunction<T> 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<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;
}
}
enum HtmlComparisonMode {
everything,
layoutOnly,
nonLayoutOnly,
noAttributes,
}
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;
}
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 styleValue = original.attributes['style'];
int attrCount = 0;
String processedAttributes = styleValue
.split(';')
.map((attr) {
attr = attr.trim();
if (attr.isEmpty) {
return null;
}
if (mode != HtmlComparisonMode.everything) {
final forLayout = mode == HtmlComparisonMode.layoutOnly;
List<String> parts = attr.split(':');
if (parts.length == 2) {
String name = parts.first;
bool isStaticAttribute = const <String>[
'top',
'left',
'position',
].contains(name);
if (isStaticAttribute) {
return _unusedAttribute(name);
}
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((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;
}
html_package.DocumentFragment originalDom =
html_package.parseFragment(htmlContent);
html_package.DocumentFragment cleanDom = html_package.DocumentFragment();
for (var child in originalDom.children) {
cleanDom.append(_cleanup(child));
}
return cleanDom.outerHtml;
}
void expectHtml(html.Element element, String expectedHtml,
{HtmlComparisonMode mode = HtmlComparisonMode.nonLayoutOnly}) {
expectedHtml =
canonicalizeHtml(expectedHtml, mode: mode, throwOnUnusedAttributes: true);
String actualHtml = canonicalizeHtml(element.outerHtml, mode: mode);
expect(actualHtml, expectedHtml);
}
void expectPageHtml(String expectedHtml,
{HtmlComparisonMode mode = HtmlComparisonMode.nonLayoutOnly}) {
expectedHtml = canonicalizeHtml(expectedHtml, mode: mode);
String actualHtml = canonicalizeHtml(currentHtml, mode: mode);
expect(actualHtml, expectedHtml);
}
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);
}
}