Skip to content

Commit 399cd6a

Browse files
Refactors accessibility guidelines to remove the single window assumption. (flutter#122760)
Refactors accessibility guidelines to remove the single window assumption
1 parent 3dd3c02 commit 399cd6a

File tree

2 files changed

+135
-112
lines changed

2 files changed

+135
-112
lines changed

packages/flutter_test/lib/src/accessibility.dart

Lines changed: 130 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import 'dart:async';
66
import 'dart:ui' as ui;
7+
import 'dart:ui';
78

89
import 'package:flutter/foundation.dart';
910
import 'package:flutter/rendering.dart';
@@ -42,11 +43,11 @@ class Evaluation {
4243
}
4344

4445
final StringBuffer buffer = StringBuffer();
45-
if (reason != null) {
46+
if (reason != null && reason!.isNotEmpty) {
4647
buffer.write(reason);
47-
buffer.write(' ');
48+
buffer.writeln();
4849
}
49-
if (other.reason != null) {
50+
if (other.reason != null && other.reason!.isNotEmpty) {
5051
buffer.write(other.reason);
5152
}
5253
return Evaluation._(
@@ -122,16 +123,22 @@ class MinimumTapTargetGuideline extends AccessibilityGuideline {
122123

123124
@override
124125
FutureOr<Evaluation> evaluate(WidgetTester tester) {
125-
return _traverse(
126-
tester,
127-
tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!,
128-
);
126+
Evaluation result = const Evaluation.pass();
127+
for (final FlutterView view in tester.platformDispatcher.views) {
128+
result += _traverse(
129+
view,
130+
// TODO(pdblasi-google): Get the specific semantics root for this view when available
131+
tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!,
132+
);
133+
}
134+
135+
return result;
129136
}
130137

131-
Evaluation _traverse(WidgetTester tester, SemanticsNode node) {
138+
Evaluation _traverse(FlutterView view, SemanticsNode node) {
132139
Evaluation result = const Evaluation.pass();
133140
node.visitChildren((SemanticsNode child) {
134-
result += _traverse(tester, child);
141+
result += _traverse(view, child);
135142
return true;
136143
});
137144
if (node.isMergedIntoParent) {
@@ -152,15 +159,15 @@ class MinimumTapTargetGuideline extends AccessibilityGuideline {
152159
// skip node if it is touching the edge of the screen, since it might
153160
// be partially scrolled offscreen.
154161
const double delta = 0.001;
155-
final Size physicalSize = tester.binding.window.physicalSize;
162+
final Size physicalSize = view.physicalSize;
156163
if (paintBounds.left <= delta ||
157164
paintBounds.top <= delta ||
158165
(paintBounds.bottom - physicalSize.height).abs() <= delta ||
159166
(paintBounds.right - physicalSize.width).abs() <= delta) {
160167
return result;
161168
}
162169
// shrink by device pixel ratio.
163-
final Size candidateSize = paintBounds.size / tester.binding.window.devicePixelRatio;
170+
final Size candidateSize = paintBounds.size / view.devicePixelRatio;
164171
if (candidateSize.width < size.width - delta ||
165172
candidateSize.height < size.height - delta) {
166173
result += Evaluation.fail(
@@ -210,35 +217,42 @@ class LabeledTapTargetGuideline extends AccessibilityGuideline {
210217

211218
@override
212219
FutureOr<Evaluation> evaluate(WidgetTester tester) {
213-
final SemanticsNode root = tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!;
214-
Evaluation traverse(SemanticsNode node) {
215-
Evaluation result = const Evaluation.pass();
216-
node.visitChildren((SemanticsNode child) {
217-
result += traverse(child);
218-
return true;
219-
});
220-
if (node.isMergedIntoParent ||
221-
node.isInvisible ||
222-
node.hasFlag(ui.SemanticsFlag.isHidden) ||
223-
node.hasFlag(ui.SemanticsFlag.isTextField)) {
224-
return result;
225-
}
226-
final SemanticsData data = node.getSemanticsData();
227-
// Skip node if it has no actions, or is marked as hidden.
228-
if (!data.hasAction(ui.SemanticsAction.longPress) &&
229-
!data.hasAction(ui.SemanticsAction.tap)) {
230-
return result;
231-
}
232-
if ((data.label.isEmpty) && (data.tooltip.isEmpty)) {
233-
result += Evaluation.fail(
234-
'$node: expected tappable node to have semantic label, '
235-
'but none was found.\n',
236-
);
237-
}
238-
return result;
220+
Evaluation result = const Evaluation.pass();
221+
222+
// TODO(pdblasi-google): Use view to retrieve the appropriate root semantics node when available.
223+
// ignore: unused_local_variable
224+
for (final FlutterView view in tester.platformDispatcher.views) {
225+
result += _traverse(tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!);
239226
}
240227

241-
return traverse(root);
228+
return result;
229+
}
230+
231+
Evaluation _traverse(SemanticsNode node) {
232+
Evaluation result = const Evaluation.pass();
233+
node.visitChildren((SemanticsNode child) {
234+
result += _traverse(child);
235+
return true;
236+
});
237+
if (node.isMergedIntoParent ||
238+
node.isInvisible ||
239+
node.hasFlag(ui.SemanticsFlag.isHidden) ||
240+
node.hasFlag(ui.SemanticsFlag.isTextField)) {
241+
return result;
242+
}
243+
final SemanticsData data = node.getSemanticsData();
244+
// Skip node if it has no actions, or is marked as hidden.
245+
if (!data.hasAction(ui.SemanticsAction.longPress) &&
246+
!data.hasAction(ui.SemanticsAction.tap)) {
247+
return result;
248+
}
249+
if ((data.label.isEmpty) && (data.tooltip.isEmpty)) {
250+
result += Evaluation.fail(
251+
'$node: expected tappable node to have semantic label, '
252+
'but none was found.',
253+
);
254+
}
255+
return result;
242256
}
243257
}
244258

@@ -283,29 +297,36 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
283297

284298
@override
285299
Future<Evaluation> evaluate(WidgetTester tester) async {
286-
final SemanticsNode root = tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!;
287-
final RenderView renderView = tester.binding.renderView;
288-
final OffsetLayer layer = renderView.debugLayer! as OffsetLayer;
289-
290-
late ui.Image image;
291-
final ByteData? byteData = await tester.binding.runAsync<ByteData?>(
292-
() async {
293-
// Needs to be the same pixel ratio otherwise our dimensions won't match
294-
// the last transform layer.
295-
final double ratio = 1 / tester.binding.window.devicePixelRatio;
296-
image = await layer.toImage(renderView.paintBounds, pixelRatio: ratio);
297-
return image.toByteData();
298-
},
299-
);
300+
Evaluation result = const Evaluation.pass();
301+
for (final FlutterView view in tester.platformDispatcher.views) {
302+
// TODO(pdblasi): This renderView will need to be retrieved from view when available.
303+
final RenderView renderView = tester.binding.renderView;
304+
final OffsetLayer layer = renderView.debugLayer! as OffsetLayer;
305+
final SemanticsNode root = renderView.owner!.semanticsOwner!.rootSemanticsNode!;
306+
307+
late ui.Image image;
308+
final ByteData? byteData = await tester.binding.runAsync<ByteData?>(
309+
() async {
310+
// Needs to be the same pixel ratio otherwise our dimensions won't match
311+
// the last transform layer.
312+
final double ratio = 1 / view.devicePixelRatio;
313+
image = await layer.toImage(renderView.paintBounds, pixelRatio: ratio);
314+
return image.toByteData();
315+
},
316+
);
300317

301-
return _evaluateNode(root, tester, image, byteData!);
318+
result += await _evaluateNode(root, tester, image, byteData!, view);
319+
}
320+
321+
return result;
302322
}
303323

304324
Future<Evaluation> _evaluateNode(
305325
SemanticsNode node,
306326
WidgetTester tester,
307327
ui.Image image,
308328
ByteData byteData,
329+
FlutterView view,
309330
) async {
310331
Evaluation result = const Evaluation.pass();
311332

@@ -327,15 +348,15 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
327348
return true;
328349
});
329350
for (final SemanticsNode child in children) {
330-
result += await _evaluateNode(child, tester, image, byteData);
351+
result += await _evaluateNode(child, tester, image, byteData, view);
331352
}
332353
if (shouldSkipNode(data)) {
333354
return result;
334355
}
335356
final String text = data.label.isEmpty ? data.value : data.label;
336357
final Iterable<Element> elements = find.text(text).hitTestable().evaluate();
337358
for (final Element element in elements) {
338-
result += await _evaluateElement(node, element, tester, image, byteData);
359+
result += await _evaluateElement(node, element, tester, image, byteData, view);
339360
}
340361
return result;
341362
}
@@ -346,6 +367,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
346367
WidgetTester tester,
347368
ui.Image image,
348369
ByteData byteData,
370+
FlutterView view,
349371
) async {
350372
// Look up inherited text properties to determine text size and weight.
351373
late bool isBold;
@@ -401,7 +423,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
401423
throw StateError('Unexpected widget type: ${widget.runtimeType}');
402424
}
403425

404-
if (isNodeOffScreen(paintBoundsWithOffset, tester.binding.window)) {
426+
if (isNodeOffScreen(paintBoundsWithOffset, view)) {
405427
return const Evaluation.pass();
406428
}
407429

@@ -512,69 +534,72 @@ class CustomMinimumContrastGuideline extends AccessibilityGuideline {
512534
@override
513535
Future<Evaluation> evaluate(WidgetTester tester) async {
514536
// Compute elements to be evaluated.
515-
516537
final List<Element> elements = finder.evaluate().toList();
538+
final Map<FlutterView, ui.Image> images = <FlutterView, ui.Image>{};
539+
final Map<FlutterView, ByteData> byteDatas = <FlutterView, ByteData>{};
517540

518-
// Obtain rendered image.
519-
520-
final RenderView renderView = tester.binding.renderView;
521-
final OffsetLayer layer = renderView.debugLayer! as OffsetLayer;
522-
late ui.Image image;
523-
final ByteData? byteData = await tester.binding.runAsync<ByteData?>(
524-
() async {
525-
// Needs to be the same pixel ratio otherwise our dimensions won't match
526-
// the last transform layer.
527-
final double ratio = 1 / tester.binding.window.devicePixelRatio;
528-
image = await layer.toImage(renderView.paintBounds, pixelRatio: ratio);
529-
return image.toByteData();
530-
},
531-
);
541+
// Collate all evaluations into a final evaluation, then return.
542+
Evaluation result = const Evaluation.pass();
543+
for (final Element element in elements) {
544+
final FlutterView view = tester.viewOf(find.byElementPredicate((Element e) => e == element));
545+
546+
// TODO(pdblasi): Obtain this renderView from view when possible.
547+
final RenderView renderView = tester.binding.renderView;
548+
final OffsetLayer layer = renderView.debugLayer! as OffsetLayer;
549+
550+
late final ui.Image image;
551+
late final ByteData byteData;
552+
553+
// Obtain a previously rendered image or render one for a new view.
554+
await tester.binding.runAsync(() async {
555+
image = images[view] ??= await layer.toImage(
556+
renderView.paintBounds,
557+
// Needs to be the same pixel ratio otherwise our dimensions
558+
// won't match the last transform layer.
559+
pixelRatio: 1 / view.devicePixelRatio,
560+
);
561+
byteData = byteDatas[view] ??= (await image.toByteData())!;
562+
});
532563

533-
// How to evaluate a single element.
564+
result = result + _evaluateElement(element, byteData, image);
565+
}
534566

535-
Evaluation evaluateElement(Element element) {
536-
final RenderBox renderObject = element.renderObject! as RenderBox;
567+
return result;
568+
}
537569

538-
final Rect originalPaintBounds = renderObject.paintBounds;
570+
// How to evaluate a single element.
571+
Evaluation _evaluateElement(Element element, ByteData byteData, ui.Image image) {
572+
final RenderBox renderObject = element.renderObject! as RenderBox;
539573

540-
final Rect inflatedPaintBounds = originalPaintBounds.inflate(4.0);
574+
final Rect originalPaintBounds = renderObject.paintBounds;
541575

542-
final Rect paintBounds = Rect.fromPoints(
543-
renderObject.localToGlobal(inflatedPaintBounds.topLeft),
544-
renderObject.localToGlobal(inflatedPaintBounds.bottomRight),
545-
);
576+
final Rect inflatedPaintBounds = originalPaintBounds.inflate(4.0);
546577

547-
final Map<Color, int> colorHistogram = _colorsWithinRect(byteData!, paintBounds, image.width, image.height);
578+
final Rect paintBounds = Rect.fromPoints(
579+
renderObject.localToGlobal(inflatedPaintBounds.topLeft),
580+
renderObject.localToGlobal(inflatedPaintBounds.bottomRight),
581+
);
548582

549-
if (colorHistogram.isEmpty) {
550-
return const Evaluation.pass();
551-
}
583+
final Map<Color, int> colorHistogram = _colorsWithinRect(byteData, paintBounds, image.width, image.height);
552584

553-
final _ContrastReport report = _ContrastReport(colorHistogram);
554-
final double contrastRatio = report.contrastRatio();
555-
556-
if (contrastRatio >= minimumRatio - tolerance) {
557-
return const Evaluation.pass();
558-
} else {
559-
return Evaluation.fail(
560-
'$element:\nExpected contrast ratio of at least '
561-
'$minimumRatio but found ${contrastRatio.toStringAsFixed(2)} \n'
562-
'The computed light color was: ${report.lightColor}, '
563-
'The computed dark color was: ${report.darkColor}\n'
564-
'$description',
565-
);
566-
}
585+
if (colorHistogram.isEmpty) {
586+
return const Evaluation.pass();
567587
}
568588

569-
// Collate all evaluations into a final evaluation, then return.
570-
571-
Evaluation result = const Evaluation.pass();
589+
final _ContrastReport report = _ContrastReport(colorHistogram);
590+
final double contrastRatio = report.contrastRatio();
572591

573-
for (final Element element in elements) {
574-
result = result + evaluateElement(element);
592+
if (contrastRatio >= minimumRatio - tolerance) {
593+
return const Evaluation.pass();
594+
} else {
595+
return Evaluation.fail(
596+
'$element:\nExpected contrast ratio of at least '
597+
'$minimumRatio but found ${contrastRatio.toStringAsFixed(2)} \n'
598+
'The computed light color was: ${report.lightColor}, '
599+
'The computed dark color was: ${report.darkColor}\n'
600+
'$description',
601+
);
575602
}
576-
577-
return result;
578603
}
579604
}
580605

packages/flutter_test/lib/src/controller.dart

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -252,19 +252,17 @@ abstract class WidgetController {
252252
/// The [TestFlutterView] provided by default when testing with
253253
/// [WidgetTester.pumpWidget].
254254
///
255-
/// If the test requires multiple views, it will need to use [viewOf] instead
256-
/// to ensure that the view related to the widget being evaluated is the one
257-
/// that gets updated.
255+
/// If the test uses multiple views, this will return the view that is painted
256+
/// into by [WidgetTester.pumpWidget]. If a different view needs to be
257+
/// accessed use [viewOf] to ensure that the view related to the widget being
258+
/// evaluated is the one that gets updated.
258259
///
259260
/// See also:
260261
///
261262
/// * [viewOf], which can find a [TestFlutterView] related to a given finder.
262263
/// This is how to modify view properties for testing when dealing with
263264
/// multiple views.
264-
TestFlutterView get view {
265-
assert(platformDispatcher.views.length == 1, 'When testing with multiple views, use `viewOf` instead.');
266-
return platformDispatcher.views.single;
267-
}
265+
TestFlutterView get view => platformDispatcher.implicitView!;
268266

269267
/// Provides access to a [SemanticsController] for testing anything related to
270268
/// the [Semantics] tree.

0 commit comments

Comments
 (0)