Unverified Commit 399cd6a5 authored by pdblasi-google's avatar pdblasi-google Committed by GitHub

Refactors accessibility guidelines to remove the single window assumption. (#122760)

Refactors accessibility guidelines to remove the single window assumption
parent 3dd3c021
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'dart:ui';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
...@@ -42,11 +43,11 @@ class Evaluation { ...@@ -42,11 +43,11 @@ class Evaluation {
} }
final StringBuffer buffer = StringBuffer(); final StringBuffer buffer = StringBuffer();
if (reason != null) { if (reason != null && reason!.isNotEmpty) {
buffer.write(reason); buffer.write(reason);
buffer.write(' '); buffer.writeln();
} }
if (other.reason != null) { if (other.reason != null && other.reason!.isNotEmpty) {
buffer.write(other.reason); buffer.write(other.reason);
} }
return Evaluation._( return Evaluation._(
...@@ -122,16 +123,22 @@ class MinimumTapTargetGuideline extends AccessibilityGuideline { ...@@ -122,16 +123,22 @@ class MinimumTapTargetGuideline extends AccessibilityGuideline {
@override @override
FutureOr<Evaluation> evaluate(WidgetTester tester) { FutureOr<Evaluation> evaluate(WidgetTester tester) {
return _traverse( Evaluation result = const Evaluation.pass();
tester, for (final FlutterView view in tester.platformDispatcher.views) {
tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!, result += _traverse(
); view,
// TODO(pdblasi-google): Get the specific semantics root for this view when available
tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!,
);
}
return result;
} }
Evaluation _traverse(WidgetTester tester, SemanticsNode node) { Evaluation _traverse(FlutterView view, SemanticsNode node) {
Evaluation result = const Evaluation.pass(); Evaluation result = const Evaluation.pass();
node.visitChildren((SemanticsNode child) { node.visitChildren((SemanticsNode child) {
result += _traverse(tester, child); result += _traverse(view, child);
return true; return true;
}); });
if (node.isMergedIntoParent) { if (node.isMergedIntoParent) {
...@@ -152,7 +159,7 @@ class MinimumTapTargetGuideline extends AccessibilityGuideline { ...@@ -152,7 +159,7 @@ class MinimumTapTargetGuideline extends AccessibilityGuideline {
// skip node if it is touching the edge of the screen, since it might // skip node if it is touching the edge of the screen, since it might
// be partially scrolled offscreen. // be partially scrolled offscreen.
const double delta = 0.001; const double delta = 0.001;
final Size physicalSize = tester.binding.window.physicalSize; final Size physicalSize = view.physicalSize;
if (paintBounds.left <= delta || if (paintBounds.left <= delta ||
paintBounds.top <= delta || paintBounds.top <= delta ||
(paintBounds.bottom - physicalSize.height).abs() <= delta || (paintBounds.bottom - physicalSize.height).abs() <= delta ||
...@@ -160,7 +167,7 @@ class MinimumTapTargetGuideline extends AccessibilityGuideline { ...@@ -160,7 +167,7 @@ class MinimumTapTargetGuideline extends AccessibilityGuideline {
return result; return result;
} }
// shrink by device pixel ratio. // shrink by device pixel ratio.
final Size candidateSize = paintBounds.size / tester.binding.window.devicePixelRatio; final Size candidateSize = paintBounds.size / view.devicePixelRatio;
if (candidateSize.width < size.width - delta || if (candidateSize.width < size.width - delta ||
candidateSize.height < size.height - delta) { candidateSize.height < size.height - delta) {
result += Evaluation.fail( result += Evaluation.fail(
...@@ -210,35 +217,42 @@ class LabeledTapTargetGuideline extends AccessibilityGuideline { ...@@ -210,35 +217,42 @@ class LabeledTapTargetGuideline extends AccessibilityGuideline {
@override @override
FutureOr<Evaluation> evaluate(WidgetTester tester) { FutureOr<Evaluation> evaluate(WidgetTester tester) {
final SemanticsNode root = tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!; Evaluation result = const Evaluation.pass();
Evaluation traverse(SemanticsNode node) {
Evaluation result = const Evaluation.pass(); // TODO(pdblasi-google): Use view to retrieve the appropriate root semantics node when available.
node.visitChildren((SemanticsNode child) { // ignore: unused_local_variable
result += traverse(child); for (final FlutterView view in tester.platformDispatcher.views) {
return true; result += _traverse(tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!);
});
if (node.isMergedIntoParent ||
node.isInvisible ||
node.hasFlag(ui.SemanticsFlag.isHidden) ||
node.hasFlag(ui.SemanticsFlag.isTextField)) {
return result;
}
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)) {
return result;
}
if ((data.label.isEmpty) && (data.tooltip.isEmpty)) {
result += Evaluation.fail(
'$node: expected tappable node to have semantic label, '
'but none was found.\n',
);
}
return result;
} }
return traverse(root); return result;
}
Evaluation _traverse(SemanticsNode node) {
Evaluation result = const Evaluation.pass();
node.visitChildren((SemanticsNode child) {
result += _traverse(child);
return true;
});
if (node.isMergedIntoParent ||
node.isInvisible ||
node.hasFlag(ui.SemanticsFlag.isHidden) ||
node.hasFlag(ui.SemanticsFlag.isTextField)) {
return result;
}
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)) {
return result;
}
if ((data.label.isEmpty) && (data.tooltip.isEmpty)) {
result += Evaluation.fail(
'$node: expected tappable node to have semantic label, '
'but none was found.',
);
}
return result;
} }
} }
...@@ -283,22 +297,28 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { ...@@ -283,22 +297,28 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
@override @override
Future<Evaluation> evaluate(WidgetTester tester) async { Future<Evaluation> evaluate(WidgetTester tester) async {
final SemanticsNode root = tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!; Evaluation result = const Evaluation.pass();
final RenderView renderView = tester.binding.renderView; for (final FlutterView view in tester.platformDispatcher.views) {
final OffsetLayer layer = renderView.debugLayer! as OffsetLayer; // TODO(pdblasi): This renderView will need to be retrieved from view when available.
final RenderView renderView = tester.binding.renderView;
late ui.Image image; final OffsetLayer layer = renderView.debugLayer! as OffsetLayer;
final ByteData? byteData = await tester.binding.runAsync<ByteData?>( final SemanticsNode root = renderView.owner!.semanticsOwner!.rootSemanticsNode!;
() async {
// Needs to be the same pixel ratio otherwise our dimensions won't match late ui.Image image;
// the last transform layer. final ByteData? byteData = await tester.binding.runAsync<ByteData?>(
final double ratio = 1 / tester.binding.window.devicePixelRatio; () async {
image = await layer.toImage(renderView.paintBounds, pixelRatio: ratio); // Needs to be the same pixel ratio otherwise our dimensions won't match
return image.toByteData(); // the last transform layer.
}, final double ratio = 1 / view.devicePixelRatio;
); image = await layer.toImage(renderView.paintBounds, pixelRatio: ratio);
return image.toByteData();
},
);
return _evaluateNode(root, tester, image, byteData!); result += await _evaluateNode(root, tester, image, byteData!, view);
}
return result;
} }
Future<Evaluation> _evaluateNode( Future<Evaluation> _evaluateNode(
...@@ -306,6 +326,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { ...@@ -306,6 +326,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
WidgetTester tester, WidgetTester tester,
ui.Image image, ui.Image image,
ByteData byteData, ByteData byteData,
FlutterView view,
) async { ) async {
Evaluation result = const Evaluation.pass(); Evaluation result = const Evaluation.pass();
...@@ -327,7 +348,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { ...@@ -327,7 +348,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
return true; return true;
}); });
for (final SemanticsNode child in children) { for (final SemanticsNode child in children) {
result += await _evaluateNode(child, tester, image, byteData); result += await _evaluateNode(child, tester, image, byteData, view);
} }
if (shouldSkipNode(data)) { if (shouldSkipNode(data)) {
return result; return result;
...@@ -335,7 +356,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { ...@@ -335,7 +356,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
final String text = data.label.isEmpty ? data.value : data.label; final String text = data.label.isEmpty ? data.value : data.label;
final Iterable<Element> elements = find.text(text).hitTestable().evaluate(); final Iterable<Element> elements = find.text(text).hitTestable().evaluate();
for (final Element element in elements) { for (final Element element in elements) {
result += await _evaluateElement(node, element, tester, image, byteData); result += await _evaluateElement(node, element, tester, image, byteData, view);
} }
return result; return result;
} }
...@@ -346,6 +367,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { ...@@ -346,6 +367,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
WidgetTester tester, WidgetTester tester,
ui.Image image, ui.Image image,
ByteData byteData, ByteData byteData,
FlutterView view,
) async { ) async {
// Look up inherited text properties to determine text size and weight. // Look up inherited text properties to determine text size and weight.
late bool isBold; late bool isBold;
...@@ -401,7 +423,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { ...@@ -401,7 +423,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
throw StateError('Unexpected widget type: ${widget.runtimeType}'); throw StateError('Unexpected widget type: ${widget.runtimeType}');
} }
if (isNodeOffScreen(paintBoundsWithOffset, tester.binding.window)) { if (isNodeOffScreen(paintBoundsWithOffset, view)) {
return const Evaluation.pass(); return const Evaluation.pass();
} }
...@@ -512,69 +534,72 @@ class CustomMinimumContrastGuideline extends AccessibilityGuideline { ...@@ -512,69 +534,72 @@ class CustomMinimumContrastGuideline extends AccessibilityGuideline {
@override @override
Future<Evaluation> evaluate(WidgetTester tester) async { Future<Evaluation> evaluate(WidgetTester tester) async {
// Compute elements to be evaluated. // Compute elements to be evaluated.
final List<Element> elements = finder.evaluate().toList(); final List<Element> elements = finder.evaluate().toList();
final Map<FlutterView, ui.Image> images = <FlutterView, ui.Image>{};
final Map<FlutterView, ByteData> byteDatas = <FlutterView, ByteData>{};
// Obtain rendered image. // Collate all evaluations into a final evaluation, then return.
Evaluation result = const Evaluation.pass();
final RenderView renderView = tester.binding.renderView; for (final Element element in elements) {
final OffsetLayer layer = renderView.debugLayer! as OffsetLayer; final FlutterView view = tester.viewOf(find.byElementPredicate((Element e) => e == element));
late ui.Image image;
final ByteData? byteData = await tester.binding.runAsync<ByteData?>( // TODO(pdblasi): Obtain this renderView from view when possible.
() async { final RenderView renderView = tester.binding.renderView;
// Needs to be the same pixel ratio otherwise our dimensions won't match final OffsetLayer layer = renderView.debugLayer! as OffsetLayer;
// the last transform layer.
final double ratio = 1 / tester.binding.window.devicePixelRatio; late final ui.Image image;
image = await layer.toImage(renderView.paintBounds, pixelRatio: ratio); late final ByteData byteData;
return image.toByteData();
}, // Obtain a previously rendered image or render one for a new view.
); await tester.binding.runAsync(() async {
image = images[view] ??= await layer.toImage(
renderView.paintBounds,
// Needs to be the same pixel ratio otherwise our dimensions
// won't match the last transform layer.
pixelRatio: 1 / view.devicePixelRatio,
);
byteData = byteDatas[view] ??= (await image.toByteData())!;
});
// How to evaluate a single element. result = result + _evaluateElement(element, byteData, image);
}
Evaluation evaluateElement(Element element) { return result;
final RenderBox renderObject = element.renderObject! as RenderBox; }
final Rect originalPaintBounds = renderObject.paintBounds; // How to evaluate a single element.
Evaluation _evaluateElement(Element element, ByteData byteData, ui.Image image) {
final RenderBox renderObject = element.renderObject! as RenderBox;
final Rect inflatedPaintBounds = originalPaintBounds.inflate(4.0); final Rect originalPaintBounds = renderObject.paintBounds;
final Rect paintBounds = Rect.fromPoints( final Rect inflatedPaintBounds = originalPaintBounds.inflate(4.0);
renderObject.localToGlobal(inflatedPaintBounds.topLeft),
renderObject.localToGlobal(inflatedPaintBounds.bottomRight),
);
final Map<Color, int> colorHistogram = _colorsWithinRect(byteData!, paintBounds, image.width, image.height); final Rect paintBounds = Rect.fromPoints(
renderObject.localToGlobal(inflatedPaintBounds.topLeft),
renderObject.localToGlobal(inflatedPaintBounds.bottomRight),
);
if (colorHistogram.isEmpty) { final Map<Color, int> colorHistogram = _colorsWithinRect(byteData, paintBounds, image.width, image.height);
return const Evaluation.pass();
}
final _ContrastReport report = _ContrastReport(colorHistogram); if (colorHistogram.isEmpty) {
final double contrastRatio = report.contrastRatio(); return const Evaluation.pass();
if (contrastRatio >= minimumRatio - tolerance) {
return const Evaluation.pass();
} else {
return Evaluation.fail(
'$element:\nExpected contrast ratio of at least '
'$minimumRatio but found ${contrastRatio.toStringAsFixed(2)} \n'
'The computed light color was: ${report.lightColor}, '
'The computed dark color was: ${report.darkColor}\n'
'$description',
);
}
} }
// Collate all evaluations into a final evaluation, then return. final _ContrastReport report = _ContrastReport(colorHistogram);
final double contrastRatio = report.contrastRatio();
Evaluation result = const Evaluation.pass();
for (final Element element in elements) { if (contrastRatio >= minimumRatio - tolerance) {
result = result + evaluateElement(element); return const Evaluation.pass();
} else {
return Evaluation.fail(
'$element:\nExpected contrast ratio of at least '
'$minimumRatio but found ${contrastRatio.toStringAsFixed(2)} \n'
'The computed light color was: ${report.lightColor}, '
'The computed dark color was: ${report.darkColor}\n'
'$description',
);
} }
return result;
} }
} }
......
...@@ -252,19 +252,17 @@ abstract class WidgetController { ...@@ -252,19 +252,17 @@ abstract class WidgetController {
/// The [TestFlutterView] provided by default when testing with /// The [TestFlutterView] provided by default when testing with
/// [WidgetTester.pumpWidget]. /// [WidgetTester.pumpWidget].
/// ///
/// If the test requires multiple views, it will need to use [viewOf] instead /// If the test uses multiple views, this will return the view that is painted
/// to ensure that the view related to the widget being evaluated is the one /// into by [WidgetTester.pumpWidget]. If a different view needs to be
/// that gets updated. /// accessed use [viewOf] to ensure that the view related to the widget being
/// evaluated is the one that gets updated.
/// ///
/// See also: /// See also:
/// ///
/// * [viewOf], which can find a [TestFlutterView] related to a given finder. /// * [viewOf], which can find a [TestFlutterView] related to a given finder.
/// This is how to modify view properties for testing when dealing with /// This is how to modify view properties for testing when dealing with
/// multiple views. /// multiple views.
TestFlutterView get view { TestFlutterView get view => platformDispatcher.implicitView!;
assert(platformDispatcher.views.length == 1, 'When testing with multiple views, use `viewOf` instead.');
return platformDispatcher.views.single;
}
/// Provides access to a [SemanticsController] for testing anything related to /// Provides access to a [SemanticsController] for testing anything related to
/// the [Semantics] tree. /// the [Semantics] tree.
......
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