diff --git a/examples/flutter_gallery/test/accessibility_test.dart b/examples/flutter_gallery/test/accessibility_test.dart new file mode 100644 index 00000000000..da2f916f85d --- /dev/null +++ b/examples/flutter_gallery/test/accessibility_test.dart @@ -0,0 +1,471 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_gallery/demo/all.dart'; + +void main() { + group('All material demos meet recommended tap target sizes', () { + testWidgets('backdrop_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new BackdropDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('bottom_app_bar_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new BottomAppBarDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('bottom_navigation_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new BottomNavigationDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('buttons_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new ButtonsDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('cards_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new CardsDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('chip_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new ChipDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('data_table_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new DataTableDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('date_and_time_picker_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new DateAndTimePickerDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }, skip: true); // https://github.com/flutter/flutter/issues/21578 + + testWidgets('dialog_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new DialogDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('drawer_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new DrawerDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('elevation_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new ElevationDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('expansion_panels_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new ExpansionPanelsDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('grid_list_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: const GridListDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('icons_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new IconsDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('leave_behind_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: const LeaveBehindDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('list_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: const ListDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('menu_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: const MenuDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('modal_bottom_sheet_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new ModalBottomSheetDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('overscroll_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: const OverscrollDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('page_selector_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new PageSelectorDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('persistent_bottom_sheet_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new PersistentBottomSheetDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('progress_indicator_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new ProgressIndicatorDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('reorderable_list_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: const ReorderableListDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('scrollable_tabs_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new ScrollableTabsDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('search_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new SearchDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('selection_controls_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new SelectionControlsDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('slider_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new SliderDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('snack_bar_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: const SnackBarDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('tabs_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new TabsDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('tabs_fab_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new TabsFabDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('text_form_field_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: const TextFormFieldDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('tooltip_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new TooltipDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('two_level_list_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new TwoLevelListDemo())); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + }); + + group('All material demos meet text contrast guidelines', () { + testWidgets('backdrop_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new BackdropDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('bottom_app_bar_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new BottomAppBarDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }, skip: true); // https://github.com/flutter/flutter/issues/21651 + + testWidgets('bottom_navigation_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new BottomNavigationDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('buttons_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new ButtonsDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }, skip: true); // https://github.com/flutter/flutter/issues/21647 + + testWidgets('cards_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new CardsDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }, skip: true); // https://github.com/flutter/flutter/issues/21651 + + testWidgets('chip_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new ChipDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }, skip: true); // https://github.com/flutter/flutter/issues/21647 + + testWidgets('data_table_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new DataTableDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }, skip: true); // https://github.com/flutter/flutter/issues/21647 + + testWidgets('date_and_time_picker_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new DateAndTimePickerDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }, skip: true); // https://github.com/flutter/flutter/issues/21647 + + testWidgets('dialog_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new DialogDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('drawer_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new DrawerDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('elevation_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new ElevationDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('expansion_panels_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new ExpansionPanelsDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('grid_list_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: const GridListDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('icons_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new IconsDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }, skip: true); // https://github.com/flutter/flutter/issues/21647 + + testWidgets('leave_behind_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: const LeaveBehindDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('list_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: const ListDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('menu_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: const MenuDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('modal_bottom_sheet_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new ModalBottomSheetDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('overscroll_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: const OverscrollDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('page_selector_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new PageSelectorDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('persistent_bottom_sheet_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new PersistentBottomSheetDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('progress_indicator_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new ProgressIndicatorDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('reorderable_list_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: const ReorderableListDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('scrollable_tabs_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new ScrollableTabsDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('search_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new SearchDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }, skip: true); // https://github.com/flutter/flutter/issues/21651 + + testWidgets('selection_controls_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new SelectionControlsDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('slider_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new SliderDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('snack_bar_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: const SnackBarDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('tabs_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new TabsDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('tabs_fab_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new TabsFabDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('text_form_field_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: const TextFormFieldDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('tooltip_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new TooltipDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('two_level_list_demo', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget(new MaterialApp(home: new TwoLevelListDemo())); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + }); +} \ No newline at end of file diff --git a/packages/flutter_test/lib/src/accessibility.dart b/packages/flutter_test/lib/src/accessibility.dart index add9240c134..ed44b122fc7 100644 --- a/packages/flutter_test/lib/src/accessibility.dart +++ b/packages/flutter_test/lib/src/accessibility.dart @@ -40,8 +40,10 @@ class Evaluation { if (other == null) return this; final StringBuffer buffer = new StringBuffer(); - if (reason != null) + if (reason != null) { buffer.write(reason); + buffer.write(' '); + } if (other.reason != null) buffer.write(other.reason); return new Evaluation._(passed && other.passed, buffer.isEmpty ? null : buffer.toString()); @@ -84,8 +86,13 @@ class MinimumTapTargetGuideline extends AccessibilityGuideline { result += traverse(child); return true; }); + if (node.isMergedIntoParent) + return result; 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; Rect paintBounds = node.rect; SemanticsNode current = node; @@ -94,6 +101,14 @@ class MinimumTapTargetGuideline extends AccessibilityGuideline { paintBounds = MatrixUtils.transformRect(current.transform, paintBounds); 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. final Size candidateSize = paintBounds.size / ui.window.devicePixelRatio; if (candidateSize.width < size.width || candidateSize.height < size.height) @@ -146,6 +161,8 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { final OffsetLayer layer = renderView.layer; ui.Image image; final ByteData byteData = await tester.binding.runAsync(() 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); return image.toByteData(); }); @@ -168,7 +185,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { double fontSize; bool isBold; final String text = (data.label?.isEmpty == true) ? data.value : data.label; - final List elements = find.text(text).evaluate().toList(); + final List elements = find.text(text).hitTestable().evaluate().toList(); if (elements.length == 1) { final Element element = elements.single; final Widget widget = element.widget; @@ -186,28 +203,38 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { assert(false); } } 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 { - // If we can't find the text node, then look up the default text - fontSize = 12.0; - isBold = false; + // If we can't find the text node then assume the label does not + // correspond to actual text. + return result; } // Transform local coordinate to screen coordinates. Rect paintBounds = node.rect; SemanticsNode current = node; - while (current != null) { + while (current != null && current.parent != null) { if (current.transform != null) paintBounds = MatrixUtils.transformRect(current.transform, paintBounds); paintBounds = paintBounds.shift(current.parent?.rect?.topLeft ?? Offset.zero); current = current.parent; } + if (_isNodeOffScreen(paintBounds)) + return result; final List 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 double contrastRatio = report.contrastRatio(); - final double targetContrastRatio = (isBold && fontSize > kBoldTextMinimumSize) ? - kMinimumRatioLargeText : kMinimumRatioNormalText; - if (contrastRatio >= targetContrastRatio) + const double delta = -0.01; + double 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 + new Evaluation.fail( '$node:\nExpected contrast ratio of at least ' @@ -229,6 +256,17 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { 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 _subsetToRect(ByteData data, Rect paintBounds, int width, int height) { final int newWidth = paintBounds.size.width.ceil(); final int newHeight = paintBounds.size.height.ceil(); @@ -241,8 +279,8 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { // Data is stored in row major order. for (int i = 0; i < data.lengthInBytes; 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) { final int r = data.getUint8(i); final int g = data.getUint8(i + 1); @@ -271,19 +309,15 @@ class _ContrastReport { final Color hslColor = new Color(colorHistogram.keys.first); 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 // by lightness and then choose the mode from each group. - final double averageLightness = colorHistogram.keys.fold(0.0, (double total, int color) { - return total + new HSLColor.fromColor(new Color(color)).lightness; - }) / colorHistogram.length; + double averageLightness = 0.0; + for (int color in colorHistogram.keys) { + 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 darkColor = 0; int lightCount = 0; @@ -292,14 +326,15 @@ class _ContrastReport { for (MapEntry entry in colorHistogram.entries) { final HSLColor color = new HSLColor.fromColor(new Color(entry.key)); final int count = entry.value; - if (color.lightness <= averageLightness && count > lightCount) { + if (color.lightness <= averageLightness && count > darkCount) { darkColor = entry.key; darkCount = count; - } else if (color.lightness > averageLightness && count > darkCount) { + } else if (color.lightness > averageLightness && count > lightCount) { lightColor = entry.key; lightCount = count; } } + assert (lightColor != 0 && darkColor != 0); return new _ContrastReport._(new Color(lightColor), new Color(darkColor)); } diff --git a/packages/flutter_test/test/accessibility_test.dart b/packages/flutter_test/test/accessibility_test.dart index bc40df04523..4026c9c52fe 100644 --- a/packages/flutter_test/test/accessibility_test.dart +++ b/packages/flutter_test/test/accessibility_test.dart @@ -103,7 +103,7 @@ void main() { 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(); await tester.pumpWidget(_boilerplate( new Container( @@ -121,12 +121,57 @@ void main() { expect(result.reason, '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 ' - '4.5 but found 1.17 for a font size of 14.0. ' - 'The computed foreground color was: Color(0xfffafafa), ' - 'The computed background color was: Color(0xffffeb3b)\n' + '4.5 but found 0.88 for a font size of 14.0. ' + 'The computed foreground color was: Color(0xffffeb3b), ' + 'The computed background color was: Color(0xffffff00)\n' 'See also: https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html'); 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: [ + 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', () { @@ -207,11 +252,114 @@ void main() { final Evaluation result = await androidTapTargetGuideline.evaluate(tester); expect(result.passed, false); 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' 'See also: https://support.google.com/accessibility/android/answer/7101858?hl=en'); 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: [ + 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: [ + 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: [ + 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: [ + 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(); + }); }); }