Unverified Commit 120b3deb authored by matasb-google's avatar matasb-google Committed by GitHub

Improve A11Y guidelines/tests for flutter (#100267)

* Incorrect size comparison
* Code cleanup/formatting
* Additional tests
parent 08c46bcb
...@@ -3,7 +3,6 @@ ...@@ -3,7 +3,6 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
import 'dart:ui' as ui; import 'dart:ui' as ui;
...@@ -17,8 +16,8 @@ import 'widget_tester.dart'; ...@@ -17,8 +16,8 @@ import 'widget_tester.dart';
class Evaluation { class Evaluation {
/// Create a passing evaluation. /// Create a passing evaluation.
const Evaluation.pass() const Evaluation.pass()
: passed = true, : passed = true,
reason = null; reason = null;
/// Create a failing evaluation, with an optional [reason] explaining the /// Create a failing evaluation, with an optional [reason] explaining the
/// result. /// result.
...@@ -38,16 +37,22 @@ class Evaluation { ...@@ -38,16 +37,22 @@ class Evaluation {
/// The [reason] will be concatenated with a newline, and [passed] will be /// The [reason] will be concatenated with a newline, and [passed] will be
/// combined with an `&&` operator. /// combined with an `&&` operator.
Evaluation operator +(Evaluation? other) { Evaluation operator +(Evaluation? other) {
if (other == null) if (other == null) {
return this; return this;
}
final StringBuffer buffer = StringBuffer(); final StringBuffer buffer = StringBuffer();
if (reason != null) { if (reason != null) {
buffer.write(reason); buffer.write(reason);
buffer.write(' '); buffer.write(' ');
} }
if (other.reason != null) if (other.reason != null) {
buffer.write(other.reason); buffer.write(other.reason);
return Evaluation._(passed && other.passed, buffer.isEmpty ? null : buffer.toString()); }
return Evaluation._(
passed && other.passed,
buffer.isEmpty ? null : buffer.toString(),
);
} }
} }
...@@ -70,7 +75,8 @@ abstract class AccessibilityGuideline { ...@@ -70,7 +75,8 @@ abstract class AccessibilityGuideline {
/// Each platform defines its own guidelines for minimum tap areas. /// Each platform defines its own guidelines for minimum tap areas.
@visibleForTesting @visibleForTesting
class MinimumTapTargetGuideline extends AccessibilityGuideline { class MinimumTapTargetGuideline extends AccessibilityGuideline {
const MinimumTapTargetGuideline._(this.size, this.link); /// Create a new [MinimumTapTargetGuideline].
const MinimumTapTargetGuideline({required this.size, required this.link});
/// The minimum allowed size of a tappable node. /// The minimum allowed size of a tappable node.
final Size size; final Size size;
...@@ -80,49 +86,73 @@ class MinimumTapTargetGuideline extends AccessibilityGuideline { ...@@ -80,49 +86,73 @@ class MinimumTapTargetGuideline extends AccessibilityGuideline {
@override @override
FutureOr<Evaluation> evaluate(WidgetTester tester) { FutureOr<Evaluation> evaluate(WidgetTester tester) {
final SemanticsNode root = tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!; return _traverse(
Evaluation traverse(SemanticsNode node) { tester,
Evaluation result = const Evaluation.pass(); tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!,
node.visitChildren((SemanticsNode child) { );
result += traverse(child); }
return true;
}); Evaluation _traverse(WidgetTester tester, SemanticsNode node) {
if (node.isMergedIntoParent) Evaluation result = const Evaluation.pass();
return result; node.visitChildren((SemanticsNode child) {
final SemanticsData data = node.getSemanticsData(); result += _traverse(tester, child);
// Skip node if it has no actions, or is marked as hidden. return true;
if ((!data.hasAction(ui.SemanticsAction.longPress) });
&& !data.hasAction(ui.SemanticsAction.tap)) if (node.isMergedIntoParent) {
|| data.hasFlag(ui.SemanticsFlag.isHidden)) return result;
return result; }
// Skip links https://www.w3.org/WAI/WCAG21/Understanding/target-size.html if (shouldSkipNode(node)) {
if (data.hasFlag(ui.SemanticsFlag.isLink)) return result;
return result; }
Rect paintBounds = node.rect; Rect paintBounds = node.rect;
SemanticsNode? current = node; SemanticsNode? current = node;
while (current != null) { while (current != null) {
if (current.transform != null) final Matrix4? transform = current.transform;
paintBounds = MatrixUtils.transformRect(current.transform!, paintBounds); if (transform != null) {
current = current.parent; paintBounds = MatrixUtils.transformRect(transform, paintBounds);
}
// skip node if it is touching the edge of the screen, since it might
// be partially scrolled offscreen.
const double delta = 0.001;
if (paintBounds.left <= delta
|| paintBounds.top <= delta
|| (paintBounds.bottom - tester.binding.window.physicalSize.height).abs() <= delta
|| (paintBounds.right - tester.binding.window.physicalSize.width).abs() <= delta)
return result;
// shrink by device pixel ratio.
final Size candidateSize = paintBounds.size / tester.binding.window.devicePixelRatio;
if (candidateSize.width < size.width - delta || candidateSize.height < size.height - delta) {
result += Evaluation.fail(
'$node: expected tap target size of at least $size, but found $candidateSize\n'
'See also: $link');
} }
current = current.parent;
}
// skip node if it is touching the edge of the screen, since it might
// be partially scrolled offscreen.
const double delta = 0.001;
final Size physicalSize = tester.binding.window.physicalSize;
if (paintBounds.left <= delta ||
paintBounds.top <= delta ||
(paintBounds.bottom - physicalSize.height).abs() <= delta ||
(paintBounds.right - physicalSize.width).abs() <= delta) {
return result; return result;
} }
return traverse(root); // shrink by device pixel ratio.
final Size candidateSize = paintBounds.size / tester.binding.window.devicePixelRatio;
if (candidateSize.width < size.width - delta ||
candidateSize.height < size.height - delta) {
result += Evaluation.fail(
'$node: expected tap target size of at least $size, '
'but found $candidateSize\n'
'See also: $link',
);
}
return result;
}
/// Returns whether [SemanticsNode] should be skipped for minimum tap target
/// guideline.
///
/// Skips nodes which are link, hidden, or do not have actions.
bool shouldSkipNode(SemanticsNode node) {
final SemanticsData data = node.getSemanticsData();
// Skip node if it has no actions, or is marked as hidden.
if ((!data.hasAction(ui.SemanticsAction.longPress) &&
!data.hasAction(ui.SemanticsAction.tap)) ||
data.hasFlag(ui.SemanticsFlag.isHidden)) {
return true;
}
// Skip links https://www.w3.org/WAI/WCAG21/Understanding/target-size.html
if (data.hasFlag(ui.SemanticsFlag.isLink)) {
return true;
}
return false;
} }
@override @override
...@@ -147,19 +177,26 @@ class LabeledTapTargetGuideline extends AccessibilityGuideline { ...@@ -147,19 +177,26 @@ class LabeledTapTargetGuideline extends AccessibilityGuideline {
result += traverse(child); result += traverse(child);
return true; return true;
}); });
if (node.isMergedIntoParent || node.isInvisible || node.hasFlag(ui.SemanticsFlag.isHidden)) if (node.isMergedIntoParent ||
node.isInvisible ||
node.hasFlag(ui.SemanticsFlag.isHidden)) {
return result; return result;
}
final SemanticsData data = node.getSemanticsData(); final SemanticsData data = node.getSemanticsData();
// Skip node if it has no actions, or is marked as hidden. // Skip node if it has no actions, or is marked as hidden.
if (!data.hasAction(ui.SemanticsAction.longPress) && !data.hasAction(ui.SemanticsAction.tap)) if (!data.hasAction(ui.SemanticsAction.longPress) &&
!data.hasAction(ui.SemanticsAction.tap)) {
return result; return result;
}
if (data.label == null || data.label.isEmpty) { if (data.label == null || data.label.isEmpty) {
result += Evaluation.fail( result += Evaluation.fail(
'$node: expected tappable node to have semantic label, but none was found\n', '$node: expected tappable node to have semantic label, '
'but none was found.\n',
); );
} }
return result; return result;
} }
return traverse(root); return traverse(root);
} }
} }
...@@ -171,7 +208,8 @@ class LabeledTapTargetGuideline extends AccessibilityGuideline { ...@@ -171,7 +208,8 @@ class LabeledTapTargetGuideline extends AccessibilityGuideline {
/// http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html. /// http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html.
@visibleForTesting @visibleForTesting
class MinimumTextContrastGuideline extends AccessibilityGuideline { class MinimumTextContrastGuideline extends AccessibilityGuideline {
const MinimumTextContrastGuideline._(); /// Create a new [MinimumTextContrastGuideline].
const MinimumTextContrastGuideline();
/// The minimum text size considered large for contrast checking. /// The minimum text size considered large for contrast checking.
/// ///
...@@ -196,131 +234,165 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { ...@@ -196,131 +234,165 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
static const double _kDefaultFontSize = 12.0; static const double _kDefaultFontSize = 12.0;
static const double _tolerance = -0.01;
@override @override
Future<Evaluation> evaluate(WidgetTester tester) async { Future<Evaluation> evaluate(WidgetTester tester) async {
final SemanticsNode root = tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!; final SemanticsNode root = tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!;
final RenderView renderView = tester.binding.renderView; final RenderView renderView = tester.binding.renderView;
final OffsetLayer layer = renderView.debugLayer! as OffsetLayer; final OffsetLayer layer = renderView.debugLayer! as OffsetLayer;
ui.Image? image;
final ByteData byteData = (await tester.binding.runAsync<ByteData?>(() async {
// Needs to be the same pixel ratio otherwise our dimensions won't match the
// last transform layer.
image = await layer.toImage(renderView.paintBounds, pixelRatio: 1 / tester.binding.window.devicePixelRatio);
return image!.toByteData();
}))!;
assert(image != null);
Future<Evaluation> evaluateNode(SemanticsNode node) async {
Evaluation result = const Evaluation.pass();
if (node.isInvisible ||
node.isMergedIntoParent ||
node.hasFlag(ui.SemanticsFlag.isHidden) ||
(node.hasFlag(ui.SemanticsFlag.hasEnabledState) && !node.hasFlag(ui.SemanticsFlag.isEnabled)))
return result;
final SemanticsData data = node.getSemanticsData();
final List<SemanticsNode> children = <SemanticsNode>[];
node.visitChildren((SemanticsNode child) {
children.add(child);
return true;
});
for (final SemanticsNode child in children) {
result += await evaluateNode(child);
}
if (_shouldSkipNode(data)) {
return result;
}
// We need to look up the inherited text properties to determine the late ui.Image image;
// contrast ratio based on text size/weight. final ByteData? byteData = await tester.binding.runAsync<ByteData?>(
double? fontSize; () async {
bool isBold; // Needs to be the same pixel ratio otherwise our dimensions won't match
final String text = data.label.isEmpty ? data.value : data.label; // the last transform layer.
final List<Element> elements = find.text(text).hitTestable().evaluate().toList(); final double ratio = 1 / tester.binding.window.devicePixelRatio;
Rect paintBounds; image = await layer.toImage(renderView.paintBounds, pixelRatio: ratio);
if (elements.length == 1) { return image.toByteData();
final Element element = elements.single; },
assert(element.renderObject != null && element.renderObject is RenderBox); );
final RenderBox renderObject = element.renderObject! as RenderBox;
paintBounds = Rect.fromPoints( return _evaluateNode(root, tester, image, byteData!);
renderObject.localToGlobal(renderObject.paintBounds.topLeft - const Offset(4.0, 4.0)), }
renderObject.localToGlobal(renderObject.paintBounds.bottomRight + const Offset(4.0, 4.0)),
);
final Widget widget = element.widget;
final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(element);
if (widget is Text) {
final TextStyle effectiveTextStyle = widget.style == null || widget.style!.inherit
? defaultTextStyle.style.merge(widget.style)
: widget.style!;
fontSize = effectiveTextStyle.fontSize;
isBold = effectiveTextStyle.fontWeight == FontWeight.bold;
} else if (widget is EditableText) {
isBold = widget.style.fontWeight == FontWeight.bold;
fontSize = widget.style.fontSize;
} else {
throw StateError('Unexpected widget type: ${widget.runtimeType}');
}
} else if (elements.length > 1) {
return Evaluation.fail('Multiple nodes with the same label: ${data.label}\n');
} else {
// If we can't find the text node then assume the label does not
// correspond to actual text.
return result;
}
if (_isNodeOffScreen(paintBounds, tester.binding.window)) { Future<Evaluation> _evaluateNode(
return result; SemanticsNode node,
} WidgetTester tester,
final List<int> subset = _colorsWithinRect(byteData, paintBounds, image!.width, image!.height); ui.Image image,
// Node was too far off screen. ByteData byteData,
if (subset.isEmpty) { ) async {
return result; Evaluation result = const Evaluation.pass();
}
final _ContrastReport report = _ContrastReport(subset); // Skip disabled nodes, as they not required to pass contrast check.
// If rectangle is empty, pass the test. final bool isDisabled = node.hasFlag(ui.SemanticsFlag.hasEnabledState) &&
if (report.isEmptyRect) { !node.hasFlag(ui.SemanticsFlag.isEnabled);
return result;
if (node.isInvisible ||
node.isMergedIntoParent ||
node.hasFlag(ui.SemanticsFlag.isHidden) ||
isDisabled) {
return result;
}
final SemanticsData data = node.getSemanticsData();
final List<SemanticsNode> children = <SemanticsNode>[];
node.visitChildren((SemanticsNode child) {
children.add(child);
return true;
});
for (final SemanticsNode child in children) {
result += await _evaluateNode(child, tester, image, byteData);
}
if (shouldSkipNode(data)) {
return result;
}
// Look up inherited text properties to determine text size and weight.
late bool isBold;
double? fontSize;
final String text = data.label.isEmpty ? data.value : data.label;
final List<Element> elements = find.text(text).hitTestable().evaluate().toList();
late final Rect paintBounds;
if (elements.length == 1) {
final Element element = elements.single;
final RenderObject? renderBox = element.renderObject;
if (renderBox is! RenderBox) {
throw StateError('Unexpected renderObject type: $renderBox');
} }
final double contrastRatio = report.contrastRatio();
const double delta = -0.01; const Offset offset = Offset(4.0, 4.0);
double targetContrastRatio; paintBounds = Rect.fromPoints(
if ((isBold && (fontSize ?? _kDefaultFontSize) > kBoldTextMinimumSize) || (fontSize ?? _kDefaultFontSize) > kLargeTextMinimumSize) { renderBox.localToGlobal(renderBox.paintBounds.topLeft - offset),
targetContrastRatio = kMinimumRatioLargeText; renderBox.localToGlobal(renderBox.paintBounds.bottomRight + offset),
);
final Widget widget = element.widget;
final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(element);
if (widget is Text) {
final TextStyle? style = widget.style;
final TextStyle effectiveTextStyle = style == null || style.inherit
? defaultTextStyle.style.merge(widget.style)
: style;
isBold = effectiveTextStyle.fontWeight == FontWeight.bold;
fontSize = effectiveTextStyle.fontSize;
} else if (widget is EditableText) {
isBold = widget.style.fontWeight == FontWeight.bold;
fontSize = widget.style.fontSize;
} else { } else {
targetContrastRatio = kMinimumRatioNormalText; throw StateError('Unexpected widget type: ${widget.runtimeType}');
}
if (contrastRatio - targetContrastRatio >= delta) {
return result + const Evaluation.pass();
} }
return result + Evaluation.fail( } else if (elements.length > 1) {
'$node:\nExpected contrast ratio of at least ' return Evaluation.fail(
'$targetContrastRatio but found ${contrastRatio.toStringAsFixed(2)} for a font size of $fontSize. ' 'Multiple nodes with the same label: ${data.label}\n',
'The computed light color was: ${report.lightColor}, '
'The computed dark color was: ${report.darkColor}\n'
'See also: https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html'
); );
} else {
// If we can't find the text node then assume the label does not
// correspond to actual text.
return result;
}
if (isNodeOffScreen(paintBounds, tester.binding.window)) {
return result;
}
final Map<Color, int> colorHistogram = _colorsWithinRect(byteData, paintBounds, image.width, image.height);
// Node was too far off screen.
if (colorHistogram.isEmpty) {
return result;
}
final _ContrastReport report = _ContrastReport(colorHistogram);
final double contrastRatio = report.contrastRatio();
final double targetContrastRatio = this.targetContrastRatio(fontSize, bold: isBold);
if (contrastRatio - targetContrastRatio >= _tolerance) {
return result + const Evaluation.pass();
} }
return evaluateNode(root); return result +
Evaluation.fail(
'$node:\n'
'Expected contrast ratio of at least $targetContrastRatio '
'but found ${contrastRatio.toStringAsFixed(2)} '
'for a font size of $fontSize.\n'
'The computed colors was:\n'
'light - ${report.lightColor}, dark - ${report.darkColor}\n'
'See also: '
'https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html',
);
} }
// Skip routes which might have labels, and nodes without any text. /// Returns whether node should be skipped.
bool _shouldSkipNode(SemanticsData data) { ///
if (data.hasFlag(ui.SemanticsFlag.scopesRoute)) /// Skip routes which might have labels, and nodes without any text.
return true; bool shouldSkipNode(SemanticsData data) =>
if (data.label.trim().isEmpty && data.value.trim().isEmpty) data.hasFlag(ui.SemanticsFlag.scopesRoute) ||
return true; (data.label.trim().isEmpty && data.value.trim().isEmpty);
return false;
/// Returns if a rectangle of node is off the screen.
///
/// Allows node to be of screen partially before culling the node.
bool isNodeOffScreen(Rect paintBounds, ui.FlutterView window) {
final Size windowPhysicalSize = window.physicalSize * window.devicePixelRatio;
return paintBounds.top < -50.0 ||
paintBounds.left < -50.0 ||
paintBounds.bottom > windowPhysicalSize.height + 50.0 ||
paintBounds.right > windowPhysicalSize.width + 50.0;
} }
// Returns a rect that is entirely on screen, or null if it is too far off. /// Returns the required contrast ratio for the [fontSize] and [bold] setting.
// ///
// Given a pixel buffer based on the physical window size, can we actually /// Defined by http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html
// get all the data from this node? allow a small delta overlap before double targetContrastRatio(double? fontSize, {required bool bold}) {
// culling the node. final double fontSizeOrDefault = fontSize ?? _kDefaultFontSize;
bool _isNodeOffScreen(Rect paintBounds, ui.FlutterView window) { if ((bold && fontSizeOrDefault >= kBoldTextMinimumSize) ||
return paintBounds.top < -50.0 fontSizeOrDefault >= kLargeTextMinimumSize) {
|| paintBounds.left < -50.0 return kMinimumRatioLargeText;
|| paintBounds.bottom > (window.physicalSize.height * window.devicePixelRatio) + 50.0 }
|| paintBounds.right > (window.physicalSize.width * window.devicePixelRatio) + 50.0; return kMinimumRatioNormalText;
} }
@override @override
...@@ -376,14 +448,16 @@ class CustomMinimumContrastGuideline extends AccessibilityGuideline { ...@@ -376,14 +448,16 @@ class CustomMinimumContrastGuideline extends AccessibilityGuideline {
final RenderView renderView = tester.binding.renderView; final RenderView renderView = tester.binding.renderView;
final OffsetLayer layer = renderView.debugLayer! as OffsetLayer; final OffsetLayer layer = renderView.debugLayer! as OffsetLayer;
ui.Image? image; late ui.Image image;
final ByteData byteData = (await tester.binding.runAsync<ByteData?>(() async { final ByteData? byteData = await tester.binding.runAsync<ByteData?>(
// Needs to be the same pixel ratio otherwise our dimensions won't match the () async {
// last transform layer. // Needs to be the same pixel ratio otherwise our dimensions won't match
image = await layer.toImage(renderView.paintBounds, pixelRatio: 1 / tester.binding.window.devicePixelRatio); // the last transform layer.
return image!.toByteData(); final double ratio = 1 / tester.binding.window.devicePixelRatio;
}))!; image = await layer.toImage(renderView.paintBounds, pixelRatio: ratio);
assert(image != null); return image.toByteData();
},
);
// How to evaluate a single element. // How to evaluate a single element.
...@@ -399,24 +473,24 @@ class CustomMinimumContrastGuideline extends AccessibilityGuideline { ...@@ -399,24 +473,24 @@ class CustomMinimumContrastGuideline extends AccessibilityGuideline {
renderObject.localToGlobal(inflatedPaintBounds.bottomRight), renderObject.localToGlobal(inflatedPaintBounds.bottomRight),
); );
final List<int> subset = _colorsWithinRect(byteData, paintBounds, image!.width, image!.height); final Map<Color, int> colorHistogram = _colorsWithinRect(byteData!, paintBounds, image.width, image.height);
if (subset.isEmpty) { if (colorHistogram.isEmpty) {
return const Evaluation.pass(); return const Evaluation.pass();
} }
final _ContrastReport report = _ContrastReport(subset); final _ContrastReport report = _ContrastReport(colorHistogram);
final double contrastRatio = report.contrastRatio(); final double contrastRatio = report.contrastRatio();
if (report.isEmptyRect || contrastRatio >= minimumRatio - tolerance) { if (contrastRatio >= minimumRatio - tolerance) {
return const Evaluation.pass(); return const Evaluation.pass();
} else { } else {
return Evaluation.fail( return Evaluation.fail(
'$element:\nExpected contrast ratio of at least ' '$element:\nExpected contrast ratio of at least '
'$minimumRatio but found ${contrastRatio.toStringAsFixed(2)} \n' '$minimumRatio but found ${contrastRatio.toStringAsFixed(2)} \n'
'The computed light color was: ${report.lightColor}, ' 'The computed light color was: ${report.lightColor}, '
'The computed dark color was: ${report.darkColor}\n' 'The computed dark color was: ${report.darkColor}\n'
'$description' '$description',
); );
} }
} }
...@@ -438,80 +512,47 @@ class CustomMinimumContrastGuideline extends AccessibilityGuideline { ...@@ -438,80 +512,47 @@ class CustomMinimumContrastGuideline extends AccessibilityGuideline {
/// Commonly used in accessibility testing to obtain the contrast ratio of /// Commonly used in accessibility testing to obtain the contrast ratio of
/// text widgets and other types of widgets. /// text widgets and other types of widgets.
class _ContrastReport { class _ContrastReport {
/// Generates a contrast report given a list of colors. /// Generates a contrast report given a color histogram.
///
/// Given a list of integers [colors], each representing the color of a pixel
/// on a part of the screen, generates a contrast ratio report.
/// Each colors is given in ARGB format, as is the parameter for the
/// constructor [Color].
/// ///
/// The contrast ratio of the most frequent light color and the most /// The contrast ratio of the most frequent light color and the most
/// frequent dark color is calculated. Colors are divided into light and /// frequent dark color is calculated. Colors are divided into light and
/// dark colors based on their lightness as an [HSLColor]. /// dark colors based on their lightness as an [HSLColor].
factory _ContrastReport(List<int> colors) { factory _ContrastReport(Map<Color, int> colorHistogram) {
final Map<int, int> colorHistogram = <int, int>{}; // To determine the lighter and darker color, partition the colors
for (final int color in colors) { // by HSL lightness and then choose the mode from each group.
colorHistogram[color] = (colorHistogram[color] ?? 0) + 1; double totalLightness = 0.0;
} int count = 0;
if (colorHistogram.length == 1) { for (final MapEntry<Color, int> entry in colorHistogram.entries) {
final Color hslColor = Color(colorHistogram.keys.first); totalLightness += HSLColor.fromColor(entry.key).lightness * entry.value;
return _ContrastReport._(hslColor, hslColor); count += entry.value;
} }
// to determine the lighter and darker color, partition the colors final double averageLightness = totalLightness / count;
// by lightness and then choose the mode from each group.
double averageLightness = 0.0;
for (final int color in colorHistogram.keys) {
final HSLColor hslColor = HSLColor.fromColor(Color(color));
averageLightness += hslColor.lightness * colorHistogram[color]!;
}
averageLightness /= colors.length;
assert(averageLightness != double.nan); assert(averageLightness != double.nan);
int lightColor = 0;
int darkColor = 0; MapEntry<Color, int>? lightColor;
int lightCount = 0; MapEntry<Color, int>? darkColor;
int darkCount = 0;
// Find the most frequently occurring light and dark color. // Find the most frequently occurring light and dark color.
for (final MapEntry<int, int> entry in colorHistogram.entries) { for (final MapEntry<Color, int> entry in colorHistogram.entries) {
final HSLColor color = HSLColor.fromColor(Color(entry.key)); final double lightness = HSLColor.fromColor(entry.key).lightness;
final int count = entry.value; final int count = entry.value;
if (color.lightness <= averageLightness && count > darkCount) { if (lightness <= averageLightness) {
darkColor = entry.key; if (count > (darkColor?.value ?? 0)) {
darkCount = count; darkColor = entry;
} else if (color.lightness > averageLightness && count > lightCount) { }
lightColor = entry.key; } else if (count > (lightColor?.value ?? 0)) {
lightCount = count; lightColor = entry;
} }
} }
// Depending on the number of colors present, return the correct contrast
// report.
if (lightCount > 0 && darkCount > 0) {
return _ContrastReport._(Color(lightColor), Color(darkColor));
} else if (lightCount > 0) {
return _ContrastReport.singleColor(Color(lightColor));
} else if (darkCount > 0) {
return _ContrastReport.singleColor(Color(darkColor));
} else {
return const _ContrastReport.emptyRect();
}
}
const _ContrastReport._(this.lightColor, this.darkColor) // If there is only single color, it is reported as both dark and light.
: isSingleColor = false, return _ContrastReport._(
isEmptyRect = false; lightColor?.key ?? darkColor!.key,
darkColor?.key ?? lightColor!.key,
const _ContrastReport.singleColor(Color color) );
: lightColor = color, }
darkColor = color,
isSingleColor = true,
isEmptyRect = false;
const _ContrastReport.emptyRect()
: lightColor = _transparent,
darkColor = _transparent,
isSingleColor = false,
isEmptyRect = true;
static const Color _transparent = Color(0x00000000); const _ContrastReport._(this.lightColor, this.darkColor);
/// The most frequently occurring light color. Uses [Colors.transparent] if /// The most frequently occurring light color. Uses [Colors.transparent] if
/// the rectangle is empty. /// the rectangle is empty.
...@@ -521,105 +562,75 @@ class _ContrastReport { ...@@ -521,105 +562,75 @@ class _ContrastReport {
/// the rectangle is empty. /// the rectangle is empty.
final Color darkColor; final Color darkColor;
/// Whether the rectangle contains only one color.
final bool isSingleColor;
/// Whether the rectangle contains 0 pixels.
final bool isEmptyRect;
/// Computes the contrast ratio as defined by the WCAG. /// Computes the contrast ratio as defined by the WCAG.
/// ///
/// source: https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html /// Source: https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html
double contrastRatio() { double contrastRatio() => (lightColor.computeLuminance() + 0.05) / (darkColor.computeLuminance() + 0.05);
return (_luminance(lightColor) + 0.05) / (_luminance(darkColor) + 0.05);
}
/// Relative luminance calculation.
///
/// Based on https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
static double _luminance(Color color) {
double r = color.red / 255.0;
double g = color.green / 255.0;
double b = color.blue / 255.0;
if (r <= 0.03928)
r /= 12.92;
else
r = math.pow((r + 0.055)/ 1.055, 2.4).toDouble();
if (g <= 0.03928)
g /= 12.92;
else
g = math.pow((g + 0.055)/ 1.055, 2.4).toDouble();
if (b <= 0.03928)
b /= 12.92;
else
b = math.pow((b + 0.055)/ 1.055, 2.4).toDouble();
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
} }
/// Gives the colors of all pixels inside a given rectangle on the screen. /// Gives the color histogram of all pixels inside a given rectangle on the
/// screen.
/// ///
/// Given a [ByteData] object [data], which stores the color of each pixel /// Given a [ByteData] object [data], which stores the color of each pixel
/// in row-first order, where each pixel is given in 4 bytes in RGBA order, /// in row-first order, where each pixel is given in 4 bytes in RGBA order,
/// and [paintBounds], the rectangle, /// and [paintBounds], the rectangle, and [width] and [height],
/// and [width] and [height], the dimensions of the [ByteData], // the dimensions of the [ByteData] returns color histogram.
/// returns a list of the colors of all pixels within the rectangle in Map<Color, int> _colorsWithinRect(
/// row-first order. ByteData data,
/// Rect paintBounds,
/// In the returned list, each color is represented as a 32-bit integer int width,
/// in ARGB format, similar to the parameter for the [Color] constructor. int height,
List<int> _colorsWithinRect(ByteData data, Rect paintBounds, int width, int height) { ) {
final Rect truePaintBounds = paintBounds.intersect( final Rect truePaintBounds = paintBounds.intersect(Rect.fromLTWH(0.0, 0.0, width.toDouble(), height.toDouble()));
Rect.fromLTWH(0.0, 0.0, width.toDouble(), height.toDouble()),
); final int leftX = truePaintBounds.left.floor();
final int rightX = truePaintBounds.right.ceil();
final int leftX = truePaintBounds.left.floor(); final int topY = truePaintBounds.top.floor();
final int rightX = truePaintBounds.right.ceil();
final int topY = truePaintBounds.top.floor();
final int bottomY = truePaintBounds.bottom.ceil(); final int bottomY = truePaintBounds.bottom.ceil();
final List<int> buffer = <int>[]; final Map<int, int> rgbaToCount = <int, int>{};
int _getPixel(ByteData data, int x, int y) { int _getPixel(ByteData data, int x, int y) {
final int offset = (y * width + x) * 4; final int offset = (y * width + x) * 4;
final int r = data.getUint8(offset); return data.getUint32(offset);
final int g = data.getUint8(offset + 1);
final int b = data.getUint8(offset + 2);
final int a = data.getUint8(offset + 3);
final int color = (((a & 0xff) << 24) |
((r & 0xff) << 16) |
((g & 0xff) << 8) |
((b & 0xff) << 0)) & 0xFFFFFFFF;
return color;
} }
for (int x = leftX; x < rightX; x ++) { for (int x = leftX; x < rightX; x++) {
for (int y = topY; y < bottomY; y ++) { for (int y = topY; y < bottomY; y++) {
buffer.add(_getPixel(data, x, y)); rgbaToCount.update(
_getPixel(data, x, y),
(int count) => count + 1,
ifAbsent: () => 1,
);
} }
} }
return buffer; return rgbaToCount.map<Color, int>((int rgba, int count) {
final int argb = (rgba << 24) | (rgba >> 8) & 0xFFFFFFFF;
return MapEntry<Color, int>(Color(argb), count);
});
} }
/// A guideline which requires tappable semantic nodes a minimum size of 48 by 48. /// A guideline which requires tappable semantic nodes a minimum size of
/// 48 by 48.
/// ///
/// See also: /// See also:
/// ///
/// * [Android tap target guidelines](https://support.google.com/accessibility/android/answer/7101858?hl=en). /// * [Android tap target guidelines](https://support.google.com/accessibility/android/answer/7101858?hl=en).
const AccessibilityGuideline androidTapTargetGuideline = MinimumTapTargetGuideline._( const AccessibilityGuideline androidTapTargetGuideline = MinimumTapTargetGuideline(
Size(48.0, 48.0), size: Size(48.0, 48.0),
'https://support.google.com/accessibility/android/answer/7101858?hl=en', link: 'https://support.google.com/accessibility/android/answer/7101858?hl=en',
); );
/// A guideline which requires tappable semantic nodes a minimum size of 44 by 44. /// A guideline which requires tappable semantic nodes a minimum size of
/// 44 by 44.
/// ///
/// See also: /// See also:
/// ///
/// * [iOS human interface guidelines](https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/adaptivity-and-layout/). /// * [iOS human interface guidelines](https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/adaptivity-and-layout/).
const AccessibilityGuideline iOSTapTargetGuideline = MinimumTapTargetGuideline._( const AccessibilityGuideline iOSTapTargetGuideline = MinimumTapTargetGuideline(
Size(44.0, 44.0), size: Size(44.0, 44.0),
'https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/adaptivity-and-layout/', link: 'https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/adaptivity-and-layout/',
); );
/// A guideline which requires text contrast to meet minimum values. /// A guideline which requires text contrast to meet minimum values.
...@@ -631,7 +642,7 @@ const AccessibilityGuideline iOSTapTargetGuideline = MinimumTapTargetGuideline._ ...@@ -631,7 +642,7 @@ const AccessibilityGuideline iOSTapTargetGuideline = MinimumTapTargetGuideline._
/// frequently occurring color in each partition as a representative of the /// frequently occurring color in each partition as a representative of the
/// foreground and background colors. The contrast ratio is calculated from /// foreground and background colors. The contrast ratio is calculated from
/// these colors according to the [WCAG](https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html#contrast-ratiodef) /// these colors according to the [WCAG](https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html#contrast-ratiodef)
const AccessibilityGuideline textContrastGuideline = MinimumTextContrastGuideline._(); const AccessibilityGuideline textContrastGuideline = MinimumTextContrastGuideline();
/// A guideline which enforces that all nodes with a tap or long press action /// A guideline which enforces that all nodes with a tap or long press action
/// also have a label. /// also have a label.
......
...@@ -8,72 +8,120 @@ import 'package:flutter_test/flutter_test.dart'; ...@@ -8,72 +8,120 @@ import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
group('text contrast guideline', () { group('text contrast guideline', () {
testWidgets('black text on white background - Text Widget - direct style', (WidgetTester tester) async { testWidgets('black text on white background - Text Widget - direct style',
(WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics(); final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(_boilerplate( await tester.pumpWidget(
const Text( _boilerplate(
'this is a test', const Text(
style: TextStyle(fontSize: 14.0, color: Colors.black), 'this is a test',
style: TextStyle(fontSize: 14.0, color: Colors.black),
),
), ),
)); );
await expectLater(tester, meetsGuideline(textContrastGuideline)); await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose(); handle.dispose();
}); });
testWidgets('white text on black background - Text Widget - direct style', (WidgetTester tester) async { testWidgets('white text on black background - Text Widget - direct style',
(WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics(); final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(_boilerplate( await tester.pumpWidget(
Container( _boilerplate(
width: 200.0, Container(
height: 200.0, width: 200.0,
color: Colors.black, height: 200.0,
child: const Text( color: Colors.black,
'this is a test', child: const Text(
style: TextStyle(fontSize: 14.0, color: Colors.white), 'this is a test',
style: TextStyle(fontSize: 14.0, color: Colors.white),
),
), ),
), ),
)); );
await expectLater(tester, meetsGuideline(textContrastGuideline)); await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose(); handle.dispose();
}); });
testWidgets('black text on white background - Text Widget - inherited style', (WidgetTester tester) async { const Color surface = Color(0xFFF0F0F0);
/// Shades of blue with contrast ratio of 2.9, 4.4, 4.5 from [surface].
const Color blue29 = Color(0xFF7E7EFB);
const Color blue44 = Color(0xFF5757FF);
const Color blue45 = Color(0xFF5252FF);
const List<TextStyle> textStylesMeetingGuideline = <TextStyle>[
TextStyle(color: blue44, backgroundColor: surface, fontSize: 18),
TextStyle(color: blue44, backgroundColor: surface, fontSize: 14, fontWeight: FontWeight.bold),
TextStyle(color: blue45, backgroundColor: surface),
];
const List<TextStyle> textStylesDoesNotMeetingGuideline = <TextStyle>[
TextStyle(color: blue44, backgroundColor: surface),
TextStyle(color: blue29, backgroundColor: surface, fontSize: 18),
];
Widget _appWithTextWidget(TextStyle style) => _boilerplate(
Text('this is text', style: style.copyWith(height: 30.0)),
);
for (final TextStyle style in textStylesMeetingGuideline) {
testWidgets('text with style $style', (WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(_appWithTextWidget(style));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
}
for (final TextStyle style in textStylesDoesNotMeetingGuideline) {
testWidgets('text with $style', (WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(_appWithTextWidget(style));
await expectLater(tester, doesNotMeetGuideline(textContrastGuideline));
handle.dispose();
});
}
testWidgets('black text on white background - Text Widget - direct style',
(WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics(); final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(_boilerplate( await tester.pumpWidget(
DefaultTextStyle( _boilerplate(
style: const TextStyle(fontSize: 14.0, color: Colors.black), const Text(
child: Container( 'this is a test',
color: Colors.white, style: TextStyle(fontSize: 14.0, color: Colors.black),
child: const Text('this is a test'),
), ),
), ),
)); );
await expectLater(tester, meetsGuideline(textContrastGuideline)); await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose(); handle.dispose();
}); });
testWidgets('white text on black background - Text Widget - inherited style', (WidgetTester tester) async { testWidgets('white text on black background - Text Widget - direct style',
(WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics(); final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(_boilerplate( await tester.pumpWidget(
DefaultTextStyle( _boilerplate(
style: const TextStyle(fontSize: 14.0, color: Colors.white), Container(
child: Container(
width: 200.0, width: 200.0,
height: 200.0, height: 200.0,
color: Colors.black, color: Colors.black,
child: const Text('this is a test'), child: const Text(
'this is a test',
style: TextStyle(fontSize: 14.0, color: Colors.white),
),
), ),
), ),
)); );
await expectLater(tester, meetsGuideline(textContrastGuideline)); await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose(); handle.dispose();
}); });
testWidgets('Material text field - amber on amber', (WidgetTester tester) async { testWidgets('Material text field - amber on amber',
(WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics(); final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(
home: Scaffold( _boilerplate(
body: Container( Container(
width: 200.0, width: 200.0,
height: 200.0, height: 200.0,
color: Colors.amberAccent, color: Colors.amberAccent,
...@@ -83,22 +131,20 @@ void main() { ...@@ -83,22 +131,20 @@ void main() {
), ),
), ),
), ),
)); );
await expectLater(tester, doesNotMeetGuideline(textContrastGuideline)); await expectLater(tester, doesNotMeetGuideline(textContrastGuideline));
handle.dispose(); handle.dispose();
}); });
testWidgets('Material text field - default style', (WidgetTester tester) async { testWidgets('Material text field - default style',
(WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics(); final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(
home: Scaffold( _boilerplate(
body: Center( SizedBox(
child: SizedBox( width: 100,
width: 100, child: TextField(
child: TextField( controller: TextEditingController(text: 'this is a test'),
controller: TextEditingController(text: 'this is a test'),
),
),
), ),
), ),
), ),
...@@ -108,44 +154,53 @@ void main() { ...@@ -108,44 +154,53 @@ void main() {
handle.dispose(); handle.dispose();
}); });
testWidgets('yellow text on yellow background fails with correct message', (WidgetTester tester) async { testWidgets('yellow text on yellow background fails with correct message',
(WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics(); final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(_boilerplate( await tester.pumpWidget(
Container( _boilerplate(
width: 200.0, Container(
height: 200.0, width: 200.0,
color: Colors.yellow, height: 200.0,
child: const Text( color: Colors.yellow,
'this is a test', child: const Text(
style: TextStyle(fontSize: 14.0, color: Colors.yellowAccent), 'this is a test',
style: TextStyle(fontSize: 14.0, color: Colors.yellowAccent),
),
), ),
), ),
)); );
final Evaluation result = await textContrastGuideline.evaluate(tester); final Evaluation result = await textContrastGuideline.evaluate(tester);
expect(result.passed, false); expect(result.passed, false);
expect(result.reason, expect(
'SemanticsNode#4(Rect.fromLTRB(300.0, 200.0, 500.0, 400.0), label: "this is a test",' result.reason,
' textDirection: ltr):\nExpected contrast ratio of at least ' 'SemanticsNode#4(Rect.fromLTRB(300.0, 200.0, 500.0, 400.0), '
'4.5 but found 1.17 for a font size of 14.0. The ' 'label: "this is a test", textDirection: ltr):\n'
'computed light color was: Color(0xfffafafa), The computed dark color was:' 'Expected contrast ratio of at least 4.5 but found 1.17 for a font '
' Color(0xffffeb3b)\n' 'size of 14.0.\n'
'See also: https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html'); 'The computed colors was:\n'
'light - Color(0xfffafafa), dark - Color(0xffffeb3b)\n'
'See also: https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html',
);
handle.dispose(); handle.dispose();
}); });
testWidgets('label without corresponding text is skipped', (WidgetTester tester) async { testWidgets('label without corresponding text is skipped',
(WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics(); final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(_boilerplate( await tester.pumpWidget(
Semantics( _boilerplate(
label: 'This is not text', Semantics(
container: true, label: 'This is not text',
child: const SizedBox( container: true,
width: 200.0, child: const SizedBox(
height: 200.0, width: 200.0,
child: Placeholder(), height: 200.0,
child: Placeholder(),
),
), ),
), ),
)); );
final Evaluation result = await textContrastGuideline.evaluate(tester); final Evaluation result = await textContrastGuideline.evaluate(tester);
expect(result.passed, true); expect(result.passed, true);
...@@ -154,31 +209,34 @@ void main() { ...@@ -154,31 +209,34 @@ void main() {
testWidgets('offscreen text is skipped', (WidgetTester tester) async { testWidgets('offscreen text is skipped', (WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics(); final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(_boilerplate( await tester.pumpWidget(
Stack( _boilerplate(
children: <Widget>[ Stack(
Positioned( children: <Widget>[
left: -300.0, Positioned(
child: Container( left: -300.0,
width: 200.0, child: Container(
height: 200.0, width: 200.0,
color: Colors.yellow, height: 200.0,
child: const Text( color: Colors.yellow,
'this is a test', child: const Text(
style: TextStyle(fontSize: 14.0, color: Colors.yellowAccent), 'this is a test',
style: TextStyle(fontSize: 14.0, color: Colors.yellowAccent),
),
), ),
), ),
), ],
], ),
) ),
)); );
final Evaluation result = await textContrastGuideline.evaluate(tester); final Evaluation result = await textContrastGuideline.evaluate(tester);
expect(result.passed, true); expect(result.passed, true);
handle.dispose(); handle.dispose();
}); });
testWidgets('Disabled button is excluded from text contrast guideline', (WidgetTester tester) async { testWidgets('Disabled button is excluded from text contrast guideline',
(WidgetTester tester) async {
// Regression test https://github.com/flutter/flutter/issues/94428 // Regression test https://github.com/flutter/flutter/issues/94428
final SemanticsHandle handle = tester.ensureSemantics(); final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget( await tester.pumpWidget(
...@@ -195,7 +253,7 @@ void main() { ...@@ -195,7 +253,7 @@ void main() {
), ),
), ),
), ),
) ),
); );
await expectLater(tester, meetsGuideline(textContrastGuideline)); await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose(); handle.dispose();
...@@ -203,7 +261,11 @@ void main() { ...@@ -203,7 +261,11 @@ void main() {
}); });
group('custom minimum contrast guideline', () { group('custom minimum contrast guideline', () {
Widget _icon({IconData icon = Icons.search, required Color color, required Color background}) { Widget _icon({
IconData icon = Icons.search,
required Color color,
required Color background,
}) {
return Container( return Container(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
color: background, color: background,
...@@ -211,7 +273,11 @@ void main() { ...@@ -211,7 +273,11 @@ void main() {
); );
} }
Widget _text({String text = 'Text', required Color color, required Color background}) { Widget _text({
String text = 'Text',
required Color color,
required Color background,
}) {
return Container( return Container(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
color: background, color: background,
...@@ -231,7 +297,10 @@ void main() { ...@@ -231,7 +297,10 @@ void main() {
_icon(color: Colors.black, background: Colors.white), _icon(color: Colors.black, background: Colors.white),
])); ]));
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons))); await expectLater(
tester,
meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons)),
);
}); });
testWidgets('Black icons on black background', (WidgetTester tester) async { testWidgets('Black icons on black background', (WidgetTester tester) async {
...@@ -240,16 +309,23 @@ void main() { ...@@ -240,16 +309,23 @@ void main() {
_icon(color: Colors.black, background: Colors.black), _icon(color: Colors.black, background: Colors.black),
])); ]));
await expectLater(tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findIcons))); await expectLater(
tester,
doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findIcons)),
);
}); });
testWidgets('White icons on black background ("dark mode")', (WidgetTester tester) async { testWidgets('White icons on black background ("dark mode")',
(WidgetTester tester) async {
await tester.pumpWidget(_row(<Widget>[ await tester.pumpWidget(_row(<Widget>[
_icon(color: Colors.white, background: Colors.black), _icon(color: Colors.white, background: Colors.black),
_icon(color: Colors.white, background: Colors.black), _icon(color: Colors.white, background: Colors.black),
])); ]));
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons))); await expectLater(
tester,
meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons)),
);
}); });
testWidgets('Using different icons', (WidgetTester tester) async { testWidgets('Using different icons', (WidgetTester tester) async {
...@@ -260,19 +336,27 @@ void main() { ...@@ -260,19 +336,27 @@ void main() {
_icon(color: Colors.black, background: Colors.white, icon: Icons.beach_access), _icon(color: Colors.black, background: Colors.white, icon: Icons.beach_access),
])); ]));
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons))); await expectLater(
tester,
meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons)),
);
}); });
testWidgets('One invalid instance fails entire test', (WidgetTester tester) async { testWidgets('One invalid instance fails entire test',
(WidgetTester tester) async {
await tester.pumpWidget(_row(<Widget>[ await tester.pumpWidget(_row(<Widget>[
_icon(color: Colors.black, background: Colors.white), _icon(color: Colors.black, background: Colors.white),
_icon(color: Colors.black, background: Colors.black), _icon(color: Colors.black, background: Colors.black),
])); ]));
await expectLater(tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findIcons))); await expectLater(
tester,
doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findIcons)),
);
}); });
testWidgets('White on different colors, passing', (WidgetTester tester) async { testWidgets('White on different colors, passing',
(WidgetTester tester) async {
await tester.pumpWidget(_row(<Widget>[ await tester.pumpWidget(_row(<Widget>[
_icon(color: Colors.white, background: Colors.red[800]!, icon: Icons.more_horiz), _icon(color: Colors.white, background: Colors.red[800]!, icon: Icons.more_horiz),
_icon(color: Colors.white, background: Colors.green[800]!, icon: Icons.description), _icon(color: Colors.white, background: Colors.green[800]!, icon: Icons.description),
...@@ -280,10 +364,12 @@ void main() { ...@@ -280,10 +364,12 @@ void main() {
_icon(color: Colors.white, background: Colors.purple[800]!, icon: Icons.beach_access), _icon(color: Colors.white, background: Colors.purple[800]!, icon: Icons.beach_access),
])); ]));
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons))); await expectLater(tester,
meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons)));
}); });
testWidgets('White on different colors, failing', (WidgetTester tester) async { testWidgets('White on different colors, failing',
(WidgetTester tester) async {
await tester.pumpWidget(_row(<Widget>[ await tester.pumpWidget(_row(<Widget>[
_icon(color: Colors.white, background: Colors.red[200]!, icon: Icons.more_horiz), _icon(color: Colors.white, background: Colors.red[200]!, icon: Icons.more_horiz),
_icon(color: Colors.white, background: Colors.green[400]!, icon: Icons.description), _icon(color: Colors.white, background: Colors.green[400]!, icon: Icons.description),
...@@ -291,25 +377,36 @@ void main() { ...@@ -291,25 +377,36 @@ void main() {
_icon(color: Colors.white, background: Colors.purple[800]!, icon: Icons.beach_access), _icon(color: Colors.white, background: Colors.purple[800]!, icon: Icons.beach_access),
])); ]));
await expectLater(tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findIcons))); await expectLater(
tester,
doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findIcons)),
);
}); });
testWidgets('Absence of icons, passing', (WidgetTester tester) async { testWidgets('Absence of icons, passing', (WidgetTester tester) async {
await tester.pumpWidget(_row(<Widget>[])); await tester.pumpWidget(_row(<Widget>[]));
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons))); await expectLater(
tester,
meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons)),
);
}); });
testWidgets('Absence of icons, passing - 2nd test', (WidgetTester tester) async { testWidgets('Absence of icons, passing - 2nd test',
(WidgetTester tester) async {
await tester.pumpWidget(_row(<Widget>[ await tester.pumpWidget(_row(<Widget>[
_text(color: Colors.black, background: Colors.white), _text(color: Colors.black, background: Colors.white),
_text(color: Colors.black, background: Colors.black), _text(color: Colors.black, background: Colors.black),
])); ]));
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons))); await expectLater(
tester,
meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons)),
);
}); });
testWidgets('Guideline ignores widgets of other types', (WidgetTester tester) async { testWidgets('Guideline ignores widgets of other types',
(WidgetTester tester) async {
await tester.pumpWidget(_row(<Widget>[ await tester.pumpWidget(_row(<Widget>[
_icon(color: Colors.black, background: Colors.white), _icon(color: Colors.black, background: Colors.white),
_icon(color: Colors.black, background: Colors.white), _icon(color: Colors.black, background: Colors.white),
...@@ -317,9 +414,18 @@ void main() { ...@@ -317,9 +414,18 @@ void main() {
_text(color: Colors.black, background: Colors.black), _text(color: Colors.black, background: Colors.black),
])); ]));
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons))); await expectLater(
await expectLater(tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findTexts))); tester,
await expectLater(tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findIconsAndTexts))); meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons)),
);
await expectLater(
tester,
doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findTexts)),
);
await expectLater(
tester,
doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findIconsAndTexts)),
);
}); });
testWidgets('Custom minimum ratio - Icons', (WidgetTester tester) async { testWidgets('Custom minimum ratio - Icons', (WidgetTester tester) async {
...@@ -328,8 +434,14 @@ void main() { ...@@ -328,8 +434,14 @@ void main() {
_icon(color: Colors.black, background: Colors.white), _icon(color: Colors.black, background: Colors.white),
])); ]));
await expectLater(tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findIcons))); await expectLater(
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons, minimumRatio: 3.0))); tester,
doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findIcons)),
);
await expectLater(
tester,
meetsGuideline(CustomMinimumContrastGuideline(finder: findIcons, minimumRatio: 3.0)),
);
}); });
testWidgets('Custom minimum ratio - Texts', (WidgetTester tester) async { testWidgets('Custom minimum ratio - Texts', (WidgetTester tester) async {
...@@ -338,11 +450,19 @@ void main() { ...@@ -338,11 +450,19 @@ void main() {
_text(color: Colors.black, background: Colors.white), _text(color: Colors.black, background: Colors.white),
])); ]));
await expectLater(tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findTexts))); await expectLater(
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: findTexts, minimumRatio: 3.0))); tester,
doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findTexts)),
);
await expectLater(
tester,
meetsGuideline(CustomMinimumContrastGuideline(finder: findTexts, minimumRatio: 3.0)),
);
}); });
testWidgets('Custom minimum ratio - Different standards for icons and texts', (WidgetTester tester) async { testWidgets(
'Custom minimum ratio - Different standards for icons and texts',
(WidgetTester tester) async {
await tester.pumpWidget(_row(<Widget>[ await tester.pumpWidget(_row(<Widget>[
_icon(color: Colors.blue, background: Colors.white), _icon(color: Colors.blue, background: Colors.white),
_icon(color: Colors.black, background: Colors.white), _icon(color: Colors.black, background: Colors.white),
...@@ -350,10 +470,15 @@ void main() { ...@@ -350,10 +470,15 @@ void main() {
_text(color: Colors.black, background: Colors.white), _text(color: Colors.black, background: Colors.white),
])); ]));
await expectLater(tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findIcons))); await expectLater(
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: findTexts, minimumRatio: 3.0))); tester,
doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: findIcons)),
);
await expectLater(
tester,
meetsGuideline(CustomMinimumContrastGuideline(finder: findTexts, minimumRatio: 3.0)),
);
}); });
}); });
group('tap target size guideline', () { group('tap target size guideline', () {
...@@ -363,9 +488,7 @@ void main() { ...@@ -363,9 +488,7 @@ void main() {
SizedBox( SizedBox(
width: 48.0, width: 48.0,
height: 48.0, height: 48.0,
child: GestureDetector( child: GestureDetector(onTap: () {}),
onTap: () { },
),
), ),
)); ));
await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
...@@ -378,12 +501,13 @@ void main() { ...@@ -378,12 +501,13 @@ void main() {
SizedBox( SizedBox(
width: 47.0, width: 47.0,
height: 48.0, height: 48.0,
child: GestureDetector( child: GestureDetector(onTap: () {}),
onTap: () { },
),
), ),
)); ));
await expectLater(tester, doesNotMeetGuideline(androidTapTargetGuideline)); await expectLater(
tester,
doesNotMeetGuideline(androidTapTargetGuideline),
);
handle.dispose(); handle.dispose();
}); });
...@@ -393,16 +517,18 @@ void main() { ...@@ -393,16 +517,18 @@ void main() {
SizedBox( SizedBox(
width: 48.0, width: 48.0,
height: 47.0, height: 47.0,
child: GestureDetector( child: GestureDetector(onTap: () {}),
onTap: () { },
),
), ),
)); ));
await expectLater(tester, doesNotMeetGuideline(androidTapTargetGuideline)); await expectLater(
tester,
doesNotMeetGuideline(androidTapTargetGuideline),
);
handle.dispose(); handle.dispose();
}); });
testWidgets('Tappable box at 48 by 48 shrunk by transform', (WidgetTester tester) async { testWidgets('Tappable box at 48 by 48 shrunk by transform',
(WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics(); final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(_boilerplate( await tester.pumpWidget(_boilerplate(
Transform.scale( Transform.scale(
...@@ -410,44 +536,47 @@ void main() { ...@@ -410,44 +536,47 @@ void main() {
child: SizedBox( child: SizedBox(
width: 48.0, width: 48.0,
height: 48.0, height: 48.0,
child: GestureDetector( child: GestureDetector(onTap: () {}),
onTap: () { },
),
), ),
), ),
)); ));
await expectLater(tester, doesNotMeetGuideline(androidTapTargetGuideline)); await expectLater(
tester,
doesNotMeetGuideline(androidTapTargetGuideline),
);
handle.dispose(); handle.dispose();
}); });
testWidgets('Too small tap target fails with the correct message', (WidgetTester tester) async { testWidgets('Too small tap target fails with the correct message',
(WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics(); final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(_boilerplate( await tester.pumpWidget(_boilerplate(
SizedBox( SizedBox(
width: 48.0, width: 48.0,
height: 47.0, height: 47.0,
child: GestureDetector( child: GestureDetector(onTap: () {}),
onTap: () { },
),
), ),
)); ));
final Evaluation result = await androidTapTargetGuideline.evaluate(tester); final Evaluation result = await androidTapTargetGuideline.evaluate(tester);
expect(result.passed, false); expect(result.passed, false);
expect(result.reason, expect(
'SemanticsNode#4(Rect.fromLTRB(376.0, 276.5, 424.0, 323.5), actions: [tap]): expected tap ' result.reason,
'target size of at least Size(48.0, 48.0), but found Size(48.0, 47.0)\n' 'SemanticsNode#4(Rect.fromLTRB(376.0, 276.5, 424.0, 323.5), '
'See also: https://support.google.com/accessibility/android/answer/7101858?hl=en'); 'actions: [tap]): expected tap '
'target size of at least Size(48.0, 48.0), '
'but found Size(48.0, 47.0)\n'
'See also: https://support.google.com/accessibility/android/answer/7101858?hl=en',
);
handle.dispose(); handle.dispose();
}); });
testWidgets('Box that overlaps edge of window is skipped', (WidgetTester tester) async { testWidgets('Box that overlaps edge of window is skipped',
(WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics(); final SemanticsHandle handle = tester.ensureSemantics();
final Widget smallBox = SizedBox( final Widget smallBox = SizedBox(
width: 48.0, width: 48.0,
height: 47.0, height: 47.0,
child: GestureDetector( child: GestureDetector(onTap: () {}),
onTap: () { },
),
); );
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
...@@ -517,26 +646,25 @@ void main() { ...@@ -517,26 +646,25 @@ void main() {
handle.dispose(); handle.dispose();
}); });
testWidgets('Does not fail on mergedIntoParent child', (WidgetTester tester) async { testWidgets('Does not fail on mergedIntoParent child',
(WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics(); final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(_boilerplate( await tester.pumpWidget(_boilerplate(MergeSemantics(
MergeSemantics( child: Semantics(
child: Semantics( container: true,
container: true, child: SizedBox(
child: SizedBox( width: 50.0,
width: 50.0, height: 50.0,
height: 50.0, child: Semantics(
child: Semantics( container: true,
container: true, child: GestureDetector(
child: GestureDetector( onTap: () {},
onTap: () { }, child: const SizedBox(width: 4.0, height: 4.0),
child: const SizedBox(width: 4.0, height: 4.0),
),
), ),
), ),
), ),
) ),
)); )));
final Evaluation overlappingRightResult = await androidTapTargetGuideline.evaluate(tester); final Evaluation overlappingRightResult = await androidTapTargetGuideline.evaluate(tester);
expect(overlappingRightResult.passed, true); expect(overlappingRightResult.passed, true);
...@@ -550,13 +678,10 @@ void main() { ...@@ -550,13 +678,10 @@ void main() {
return RichText( return RichText(
text: TextSpan( text: TextSpan(
children: <InlineSpan>[ children: <InlineSpan>[
const TextSpan( const TextSpan(text: 'See examples at '),
text: 'See examples at ',
),
TextSpan( TextSpan(
text: 'flutter repo', text: 'flutter repo',
recognizer: TapGestureRecognizer() recognizer: TapGestureRecognizer()..onTap = () {},
..onTap = () { },
), ),
], ],
), ),
...@@ -578,7 +703,7 @@ void main() { ...@@ -578,7 +703,7 @@ void main() {
final SemanticsHandle handle = tester.ensureSemantics(); final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(_boilerplate(Semantics( await tester.pumpWidget(_boilerplate(Semantics(
container: true, container: true,
onTap: () { }, onTap: () {},
label: 'test', label: 'test',
child: const SizedBox(width: 10.0, height: 10.0), child: const SizedBox(width: 10.0, height: 10.0),
))); )));
...@@ -586,11 +711,12 @@ void main() { ...@@ -586,11 +711,12 @@ void main() {
expect(result.passed, true); expect(result.passed, true);
handle.dispose(); handle.dispose();
}); });
testWidgets('Fails if long-press has no label', (WidgetTester tester) async { testWidgets('Fails if long-press has no label',
(WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics(); final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(_boilerplate(Semantics( await tester.pumpWidget(_boilerplate(Semantics(
container: true, container: true,
onLongPress: () { }, onLongPress: () {},
label: '', label: '',
child: const SizedBox(width: 10.0, height: 10.0), child: const SizedBox(width: 10.0, height: 10.0),
))); )));
...@@ -603,7 +729,7 @@ void main() { ...@@ -603,7 +729,7 @@ void main() {
final SemanticsHandle handle = tester.ensureSemantics(); final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(_boilerplate(Semantics( await tester.pumpWidget(_boilerplate(Semantics(
container: true, container: true,
onTap: () { }, onTap: () {},
label: '', label: '',
child: const SizedBox(width: 10.0, height: 10.0), child: const SizedBox(width: 10.0, height: 10.0),
))); )));
...@@ -612,11 +738,12 @@ void main() { ...@@ -612,11 +738,12 @@ void main() {
handle.dispose(); handle.dispose();
}); });
testWidgets('Passes if tap is merged into labeled node', (WidgetTester tester) async { testWidgets('Passes if tap is merged into labeled node',
(WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics(); final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(_boilerplate(Semantics( await tester.pumpWidget(_boilerplate(Semantics(
container: true, container: true,
onLongPress: () { }, onLongPress: () {},
label: '', label: '',
child: Semantics( child: Semantics(
label: 'test', label: 'test',
...@@ -629,20 +756,20 @@ void main() { ...@@ -629,20 +756,20 @@ void main() {
}); });
}); });
testWidgets('regression test for material widget', (WidgetTester tester) async { testWidgets('regression test for material widget',
(WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics(); final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget( await tester.pumpWidget(MaterialApp(
MaterialApp( theme: ThemeData.light(),
theme: ThemeData.light(), home: Scaffold(
home: Scaffold( backgroundColor: Colors.white,
backgroundColor: Colors.white, body: ElevatedButton(
body: ElevatedButton( style: ElevatedButton.styleFrom(
style: ElevatedButton.styleFrom( primary: const Color(0xFFFBBC04),
primary: const Color(0xFFFBBC04), elevation: 0,
elevation: 0, ),
), onPressed: () {},
onPressed: () {}, child: const Text('Button', style: TextStyle(color: Colors.black)),
child: const Text('Button', style: TextStyle(color: Colors.black)),
), ),
), ),
)); ));
...@@ -651,8 +778,5 @@ void main() { ...@@ -651,8 +778,5 @@ void main() {
}); });
} }
Widget _boilerplate(Widget child) { Widget _boilerplate(Widget child) =>
return MaterialApp( MaterialApp(home: Scaffold(body: Center(child: child)));
home: Scaffold(body: Center(child: child)),
);
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment