Unverified Commit fa0a857d authored by Jonah Williams's avatar Jonah Williams Committed by GitHub

Add material tap target size and text contrast test to gallery (#21581)

parent 387948a2
This diff is collapsed.
...@@ -40,8 +40,10 @@ class Evaluation { ...@@ -40,8 +40,10 @@ class Evaluation {
if (other == null) if (other == null)
return this; return this;
final StringBuffer buffer = new StringBuffer(); final StringBuffer buffer = new StringBuffer();
if (reason != null) if (reason != null) {
buffer.write(reason); buffer.write(reason);
buffer.write(' ');
}
if (other.reason != null) if (other.reason != null)
buffer.write(other.reason); buffer.write(other.reason);
return new Evaluation._(passed && other.passed, buffer.isEmpty ? null : buffer.toString()); return new Evaluation._(passed && other.passed, buffer.isEmpty ? null : buffer.toString());
...@@ -84,8 +86,13 @@ class MinimumTapTargetGuideline extends AccessibilityGuideline { ...@@ -84,8 +86,13 @@ class MinimumTapTargetGuideline extends AccessibilityGuideline {
result += traverse(child); result += traverse(child);
return true; return true;
}); });
if (node.isMergedIntoParent)
return result;
final SemanticsData data = node.getSemanticsData(); final SemanticsData data = node.getSemanticsData();
if (!data.hasAction(ui.SemanticsAction.longPress) && !data.hasAction(ui.SemanticsAction.tap)) // 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 result; return result;
Rect paintBounds = node.rect; Rect paintBounds = node.rect;
SemanticsNode current = node; SemanticsNode current = node;
...@@ -94,6 +101,14 @@ class MinimumTapTargetGuideline extends AccessibilityGuideline { ...@@ -94,6 +101,14 @@ class MinimumTapTargetGuideline extends AccessibilityGuideline {
paintBounds = MatrixUtils.transformRect(current.transform, paintBounds); paintBounds = MatrixUtils.transformRect(current.transform, paintBounds);
current = current.parent; 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;
if (paintBounds.left <= delta
|| paintBounds.top <= delta
|| (paintBounds.bottom - ui.window.physicalSize.height).abs() <= delta
|| (paintBounds.right - ui.window.physicalSize.width).abs() <= delta)
return result;
// shrink by device pixel ratio. // shrink by device pixel ratio.
final Size candidateSize = paintBounds.size / ui.window.devicePixelRatio; final Size candidateSize = paintBounds.size / ui.window.devicePixelRatio;
if (candidateSize.width < size.width || candidateSize.height < size.height) if (candidateSize.width < size.width || candidateSize.height < size.height)
...@@ -146,6 +161,8 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { ...@@ -146,6 +161,8 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
final OffsetLayer layer = renderView.layer; final OffsetLayer layer = renderView.layer;
ui.Image image; ui.Image image;
final ByteData byteData = await tester.binding.runAsync<ByteData>(() async { 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.0); image = await layer.toImage(renderView.paintBounds, pixelRatio: 1.0);
return image.toByteData(); return image.toByteData();
}); });
...@@ -168,7 +185,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { ...@@ -168,7 +185,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
double fontSize; double fontSize;
bool isBold; bool isBold;
final String text = (data.label?.isEmpty == true) ? data.value : data.label; final String text = (data.label?.isEmpty == true) ? data.value : data.label;
final List<Element> elements = find.text(text).evaluate().toList(); final List<Element> elements = find.text(text).hitTestable().evaluate().toList();
if (elements.length == 1) { if (elements.length == 1) {
final Element element = elements.single; final Element element = elements.single;
final Widget widget = element.widget; final Widget widget = element.widget;
...@@ -186,28 +203,38 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { ...@@ -186,28 +203,38 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
assert(false); assert(false);
} }
} else if (elements.length > 1) { } else if (elements.length > 1) {
return const Evaluation.fail('Multiple nodes with the same label'); return new Evaluation.fail('Multiple nodes with the same label: ${data.label}\n');
} else { } else {
// If we can't find the text node, then look up the default text // If we can't find the text node then assume the label does not
fontSize = 12.0; // correspond to actual text.
isBold = false; return result;
} }
// Transform local coordinate to screen coordinates. // Transform local coordinate to screen coordinates.
Rect paintBounds = node.rect; Rect paintBounds = node.rect;
SemanticsNode current = node; SemanticsNode current = node;
while (current != null) { while (current != null && current.parent != null) {
if (current.transform != null) if (current.transform != null)
paintBounds = MatrixUtils.transformRect(current.transform, paintBounds); paintBounds = MatrixUtils.transformRect(current.transform, paintBounds);
paintBounds = paintBounds.shift(current.parent?.rect?.topLeft ?? Offset.zero); paintBounds = paintBounds.shift(current.parent?.rect?.topLeft ?? Offset.zero);
current = current.parent; current = current.parent;
} }
if (_isNodeOffScreen(paintBounds))
return result;
final List<int> subset = _subsetToRect(byteData, paintBounds, image.width, image.height); final List<int> subset = _subsetToRect(byteData, paintBounds, image.width, image.height);
// Node was too far off screen.
if (subset.isEmpty)
return result;
final _ContrastReport report = new _ContrastReport(subset); final _ContrastReport report = new _ContrastReport(subset);
final double contrastRatio = report.contrastRatio(); final double contrastRatio = report.contrastRatio();
final double targetContrastRatio = (isBold && fontSize > kBoldTextMinimumSize) ? const double delta = -0.01;
kMinimumRatioLargeText : kMinimumRatioNormalText; double targetContrastRatio;
if (contrastRatio >= targetContrastRatio) if ((isBold && fontSize > kBoldTextMinimumSize) || (fontSize ?? 12.0) > kLargeTextMinimumSize) {
targetContrastRatio = kMinimumRatioLargeText;
} else {
targetContrastRatio = kMinimumRatioNormalText;
}
if (contrastRatio - targetContrastRatio >= delta)
return result + const Evaluation.pass(); return result + const Evaluation.pass();
return result + new Evaluation.fail( return result + new Evaluation.fail(
'$node:\nExpected contrast ratio of at least ' '$node:\nExpected contrast ratio of at least '
...@@ -229,6 +256,17 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { ...@@ -229,6 +256,17 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
return false; return false;
} }
// Returns a rect that is entirely on screen, or null if it is too far off.
//
// Given an 1800 * 2400 pixel buffer, can we actually get all the data from
// this node? allow a small delta overlap before culling the node.
bool _isNodeOffScreen(Rect paintBounds) {
return paintBounds.top < -50.0
|| paintBounds.left < -50.0
|| paintBounds.bottom > 2400.0 + 50.0
|| paintBounds.right > 1800.0 + 50.0;
}
List<int> _subsetToRect(ByteData data, Rect paintBounds, int width, int height) { List<int> _subsetToRect(ByteData data, Rect paintBounds, int width, int height) {
final int newWidth = paintBounds.size.width.ceil(); final int newWidth = paintBounds.size.width.ceil();
final int newHeight = paintBounds.size.height.ceil(); final int newHeight = paintBounds.size.height.ceil();
...@@ -241,8 +279,8 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { ...@@ -241,8 +279,8 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
// Data is stored in row major order. // Data is stored in row major order.
for (int i = 0; i < data.lengthInBytes; i+=4) { for (int i = 0; i < data.lengthInBytes; i+=4) {
final int index = i ~/ 4; final int index = i ~/ 4;
final int dy = index % width; final int dx = index % width;
final int dx = index ~/ width; final int dy = index ~/ width;
if (dx >= leftX && dx <= rightX && dy >= topY && dy <= bottomY) { if (dx >= leftX && dx <= rightX && dy >= topY && dy <= bottomY) {
final int r = data.getUint8(i); final int r = data.getUint8(i);
final int g = data.getUint8(i + 1); final int g = data.getUint8(i + 1);
...@@ -271,19 +309,15 @@ class _ContrastReport { ...@@ -271,19 +309,15 @@ class _ContrastReport {
final Color hslColor = new Color(colorHistogram.keys.first); final Color hslColor = new Color(colorHistogram.keys.first);
return new _ContrastReport._(hslColor, hslColor); return new _ContrastReport._(hslColor, hslColor);
} }
if (colorHistogram.length == 2) {
final Color firstColor = new Color(colorHistogram.keys.first);
final Color lastColor = new Color(colorHistogram.keys.last);
if (firstColor.computeLuminance() < lastColor.computeLuminance()) {
return new _ContrastReport._(lastColor, firstColor);
}
return new _ContrastReport._(firstColor, lastColor);
}
// to determine the lighter and darker color, partition the colors // to determine the lighter and darker color, partition the colors
// by lightness and then choose the mode from each group. // by lightness and then choose the mode from each group.
final double averageLightness = colorHistogram.keys.fold(0.0, (double total, int color) { double averageLightness = 0.0;
return total + new HSLColor.fromColor(new Color(color)).lightness; for (int color in colorHistogram.keys) {
}) / colorHistogram.length; final HSLColor hslColor = new HSLColor.fromColor(new Color(color));
averageLightness += hslColor.lightness * colorHistogram[color];
}
averageLightness /= colors.length;
assert(averageLightness != double.nan);
int lightColor = 0; int lightColor = 0;
int darkColor = 0; int darkColor = 0;
int lightCount = 0; int lightCount = 0;
...@@ -292,14 +326,15 @@ class _ContrastReport { ...@@ -292,14 +326,15 @@ class _ContrastReport {
for (MapEntry<int, int> entry in colorHistogram.entries) { for (MapEntry<int, int> entry in colorHistogram.entries) {
final HSLColor color = new HSLColor.fromColor(new Color(entry.key)); final HSLColor color = new HSLColor.fromColor(new Color(entry.key));
final int count = entry.value; final int count = entry.value;
if (color.lightness <= averageLightness && count > lightCount) { if (color.lightness <= averageLightness && count > darkCount) {
darkColor = entry.key; darkColor = entry.key;
darkCount = count; darkCount = count;
} else if (color.lightness > averageLightness && count > darkCount) { } else if (color.lightness > averageLightness && count > lightCount) {
lightColor = entry.key; lightColor = entry.key;
lightCount = count; lightCount = count;
} }
} }
assert (lightColor != 0 && darkColor != 0);
return new _ContrastReport._(new Color(lightColor), new Color(darkColor)); return new _ContrastReport._(new Color(lightColor), new Color(darkColor));
} }
......
...@@ -103,7 +103,7 @@ void main() { ...@@ -103,7 +103,7 @@ void main() {
handle.dispose(); handle.dispose();
}); });
testWidgets('grey text on white 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(_boilerplate(
new Container( new Container(
...@@ -121,12 +121,57 @@ void main() { ...@@ -121,12 +121,57 @@ void main() {
expect(result.reason, expect(result.reason,
'SemanticsNode#21(Rect.fromLTRB(300.0, 200.0, 500.0, 400.0), label: "this is a test",' 'SemanticsNode#21(Rect.fromLTRB(300.0, 200.0, 500.0, 400.0), label: "this is a test",'
' textDirection: ltr):\nExpected contrast ratio of at least ' ' textDirection: ltr):\nExpected contrast ratio of at least '
'4.5 but found 1.17 for a font size of 14.0. ' '4.5 but found 0.88 for a font size of 14.0. '
'The computed foreground color was: Color(0xfffafafa), ' 'The computed foreground color was: Color(0xffffeb3b), '
'The computed background color was: Color(0xffffeb3b)\n' 'The computed background color was: Color(0xffffff00)\n'
'See also: https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html'); '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 {
final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(_boilerplate(
new Semantics(
label: 'This is not text',
container: true,
child: new Container(
width: 200.0,
height: 200.0,
child: const Placeholder(),
),
),
));
final Evaluation result = await textContrastGuideline.evaluate(tester);
expect(result.passed, true);
handle.dispose();
});
testWidgets('offscreen text is skipped', (WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(_boilerplate(
new Stack(
children: <Widget>[
new Positioned(
left: -300.0,
child: new Container(
width: 200.0,
height: 200.0,
color: Colors.yellow,
child: const Text(
'this is a test',
style: TextStyle(fontSize: 14.0, color: Colors.yellowAccent),
),
),
)
],
)
));
final Evaluation result = await textContrastGuideline.evaluate(tester);
expect(result.passed, true);
handle.dispose();
});
}); });
group('tap target size guideline', () { group('tap target size guideline', () {
...@@ -207,11 +252,114 @@ void main() { ...@@ -207,11 +252,114 @@ void main() {
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(result.reason,
'SemanticsNode#36(Rect.fromLTRB(376.0, 276.5, 424.0, 323.5), actions: [tap]): expected tap ' 'SemanticsNode#41(Rect.fromLTRB(376.0, 276.5, 424.0, 323.5), actions: [tap]): expected tap '
'target size of at least Size(48.0, 48.0), but found Size(48.0, 47.0)\n' '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'); '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 {
final SemanticsHandle handle = tester.ensureSemantics();
final Widget smallBox = new SizedBox(
width: 48.0,
height: 47.0,
child: new GestureDetector(
onTap: () {},
),
);
await tester.pumpWidget(
new MaterialApp(
home: new Stack(
children: <Widget>[
new Positioned(
left: 0.0,
top: -1.0,
child: smallBox,
),
],
),
),
);
final Evaluation overlappingTopResult = await androidTapTargetGuideline.evaluate(tester);
expect(overlappingTopResult.passed, true);
await tester.pumpWidget(
new MaterialApp(
home: new Stack(
children: <Widget>[
new Positioned(
left: -1.0,
top: 0.0,
child: smallBox,
),
],
),
),
);
final Evaluation overlappingLeftResult = await androidTapTargetGuideline.evaluate(tester);
expect(overlappingLeftResult.passed, true);
await tester.pumpWidget(
new MaterialApp(
home: new Stack(
children: <Widget>[
new Positioned(
bottom: -1.0,
child: smallBox,
),
],
),
),
);
final Evaluation overlappingBottomResult = await androidTapTargetGuideline.evaluate(tester);
expect(overlappingBottomResult.passed, true);
await tester.pumpWidget(
new MaterialApp(
home: new Stack(
children: <Widget>[
new Positioned(
right: -1.0,
child: smallBox,
),
],
),
),
);
final Evaluation overlappingRightResult = await androidTapTargetGuideline.evaluate(tester);
expect(overlappingRightResult.passed, true);
handle.dispose();
});
testWidgets('Does not fail on mergedIntoParent child', (WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(_boilerplate(
new MergeSemantics(
child: new Semantics(
container: true,
child: new SizedBox(
width: 50.0,
height: 50.0,
child: new Semantics(
container: true,
child: new GestureDetector(
onTap: () {},
child: const SizedBox(width: 4.0, height: 4.0),
)
)
),
)
)
));
final Evaluation overlappingRightResult = await androidTapTargetGuideline.evaluate(tester);
expect(overlappingRightResult.passed, true);
handle.dispose();
});
}); });
} }
......
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