Skip to content

Commit f477c8b

Browse files
authored
Update accessibility contrast test coverage (#109784)
1 parent fa2dead commit f477c8b

File tree

2 files changed

+123
-51
lines changed

2 files changed

+123
-51
lines changed

packages/flutter_test/lib/src/accessibility.dart

Lines changed: 68 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -331,61 +331,79 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
331331
if (shouldSkipNode(data)) {
332332
return result;
333333
}
334+
final String text = data.label.isEmpty ? data.value : data.label;
335+
final Iterable<Element> elements = find.text(text).hitTestable().evaluate();
336+
for (final Element element in elements) {
337+
result += await _evaluateElement(node, element, tester, image, byteData);
338+
}
339+
return result;
340+
}
334341

342+
Future<Evaluation> _evaluateElement(
343+
SemanticsNode node,
344+
Element element,
345+
WidgetTester tester,
346+
ui.Image image,
347+
ByteData byteData,
348+
) async {
335349
// Look up inherited text properties to determine text size and weight.
336350
late bool isBold;
337351
double? fontSize;
338352

339-
final String text = data.label.isEmpty ? data.value : data.label;
340-
final List<Element> elements = find.text(text).hitTestable().evaluate().toList();
341353
late final Rect paintBounds;
354+
late final Rect paintBoundsWithOffset;
342355

343-
if (elements.length == 1) {
344-
final Element element = elements.single;
345-
final RenderObject? renderBox = element.renderObject;
346-
if (renderBox is! RenderBox) {
347-
throw StateError('Unexpected renderObject type: $renderBox');
348-
}
356+
final RenderObject? renderBox = element.renderObject;
357+
if (renderBox is! RenderBox) {
358+
throw StateError('Unexpected renderObject type: $renderBox');
359+
}
349360

350-
const Offset offset = Offset(4.0, 4.0);
351-
paintBounds = Rect.fromPoints(
352-
renderBox.localToGlobal(renderBox.paintBounds.topLeft - offset),
353-
renderBox.localToGlobal(renderBox.paintBounds.bottomRight + offset),
354-
);
355-
final Widget widget = element.widget;
356-
final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(element);
357-
if (widget is Text) {
358-
final TextStyle? style = widget.style;
359-
final TextStyle effectiveTextStyle = style == null || style.inherit
360-
? defaultTextStyle.style.merge(widget.style)
361-
: style;
362-
isBold = effectiveTextStyle.fontWeight == FontWeight.bold;
363-
fontSize = effectiveTextStyle.fontSize;
364-
} else if (widget is EditableText) {
365-
isBold = widget.style.fontWeight == FontWeight.bold;
366-
fontSize = widget.style.fontSize;
367-
} else {
368-
throw StateError('Unexpected widget type: ${widget.runtimeType}');
369-
}
370-
} else if (elements.length > 1) {
371-
return Evaluation.fail(
372-
'Multiple nodes with the same label: ${data.label}\n',
373-
);
361+
const Offset offset = Offset(4.0, 4.0);
362+
paintBoundsWithOffset = Rect.fromPoints(
363+
renderBox.localToGlobal(renderBox.paintBounds.topLeft - offset),
364+
renderBox.localToGlobal(renderBox.paintBounds.bottomRight + offset),
365+
);
366+
367+
paintBounds = Rect.fromPoints(
368+
renderBox.localToGlobal(renderBox.paintBounds.topLeft),
369+
renderBox.localToGlobal(renderBox.paintBounds.bottomRight),
370+
);
371+
372+
final Offset? nodeOffset = node.transform != null ? MatrixUtils.getAsTranslation(node.transform!) : null;
373+
374+
final Rect nodeBounds = node.rect.shift(nodeOffset ?? Offset.zero);
375+
final Rect intersection = nodeBounds.intersect(paintBounds);
376+
if (intersection.width <= 0 || intersection.height <= 0) {
377+
// Skip this element since it doesn't correspond to the given semantic
378+
// node.
379+
return const Evaluation.pass();
380+
}
381+
382+
final Widget widget = element.widget;
383+
final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(element);
384+
if (widget is Text) {
385+
final TextStyle? style = widget.style;
386+
final TextStyle effectiveTextStyle = style == null || style.inherit
387+
? defaultTextStyle.style.merge(widget.style)
388+
: style;
389+
isBold = effectiveTextStyle.fontWeight == FontWeight.bold;
390+
fontSize = effectiveTextStyle.fontSize;
391+
} else if (widget is EditableText) {
392+
isBold = widget.style.fontWeight == FontWeight.bold;
393+
fontSize = widget.style.fontSize;
374394
} else {
375-
// If we can't find the text node then assume the label does not
376-
// correspond to actual text.
377-
return result;
395+
throw StateError('Unexpected widget type: ${widget.runtimeType}');
378396
}
379397

380-
if (isNodeOffScreen(paintBounds, tester.binding.window)) {
381-
return result;
398+
if (isNodeOffScreen(paintBoundsWithOffset, tester.binding.window)) {
399+
return const Evaluation.pass();
382400
}
383401

384-
final Map<Color, int> colorHistogram = _colorsWithinRect(byteData, paintBounds, image.width, image.height);
402+
final Map<Color, int> colorHistogram = _colorsWithinRect(byteData, paintBoundsWithOffset, image.width, image.height);
385403

386404
// Node was too far off screen.
387405
if (colorHistogram.isEmpty) {
388-
return result;
406+
return const Evaluation.pass();
389407
}
390408

391409
final _ContrastReport report = _ContrastReport(colorHistogram);
@@ -394,19 +412,18 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
394412
final double targetContrastRatio = this.targetContrastRatio(fontSize, bold: isBold);
395413

396414
if (contrastRatio - targetContrastRatio >= _tolerance) {
397-
return result + const Evaluation.pass();
415+
return const Evaluation.pass();
398416
}
399-
return result +
400-
Evaluation.fail(
401-
'$node:\n'
402-
'Expected contrast ratio of at least $targetContrastRatio '
403-
'but found ${contrastRatio.toStringAsFixed(2)} '
404-
'for a font size of $fontSize.\n'
405-
'The computed colors was:\n'
406-
'light - ${report.lightColor}, dark - ${report.darkColor}\n'
407-
'See also: '
408-
'https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html',
409-
);
417+
return Evaluation.fail(
418+
'$node:\n'
419+
'Expected contrast ratio of at least $targetContrastRatio '
420+
'but found ${contrastRatio.toStringAsFixed(2)} '
421+
'for a font size of $fontSize.\n'
422+
'The computed colors was:\n'
423+
'light - ${report.lightColor}, dark - ${report.darkColor}\n'
424+
'See also: '
425+
'https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html',
426+
);
410427
}
411428

412429
/// Returns whether node should be skipped.

packages/flutter_test/test/accessibility_test.dart

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,61 @@ void main() {
2323
handle.dispose();
2424
});
2525

26+
testWidgets('Multiple text with same label', (WidgetTester tester) async {
27+
final SemanticsHandle handle = tester.ensureSemantics();
28+
await tester.pumpWidget(
29+
_boilerplate(
30+
Column(
31+
children: const <Widget>[
32+
Text(
33+
'this is a test',
34+
style: TextStyle(fontSize: 14.0, color: Colors.black),
35+
),
36+
Text(
37+
'this is a test',
38+
style: TextStyle(fontSize: 14.0, color: Colors.black),
39+
),
40+
],
41+
),
42+
),
43+
);
44+
await expectLater(tester, meetsGuideline(textContrastGuideline));
45+
handle.dispose();
46+
});
47+
48+
testWidgets(
49+
'Multiple text with same label but Nodes excluded from '
50+
'semantic tree have failing contrast should pass a11y guideline ',
51+
(WidgetTester tester) async {
52+
final SemanticsHandle handle = tester.ensureSemantics();
53+
await tester.pumpWidget(
54+
_boilerplate(
55+
Column(
56+
children: const <Widget>[
57+
Text(
58+
'this is a test',
59+
style: TextStyle(fontSize: 14.0, color: Colors.black),
60+
),
61+
SizedBox(height: 50),
62+
Text(
63+
'this is a test',
64+
style: TextStyle(fontSize: 14.0, color: Colors.black),
65+
),
66+
SizedBox(height: 50),
67+
ExcludeSemantics(
68+
child: Text(
69+
'this is a test',
70+
style: TextStyle(fontSize: 14.0, color: Colors.white),
71+
),
72+
),
73+
],
74+
),
75+
),
76+
);
77+
await expectLater(tester, meetsGuideline(textContrastGuideline));
78+
handle.dispose();
79+
});
80+
2681
testWidgets('white text on black background - Text Widget - direct style',
2782
(WidgetTester tester) async {
2883
final SemanticsHandle handle = tester.ensureSemantics();

0 commit comments

Comments
 (0)