diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index d82651e011c70..9399b84b3f79b 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -6847,7 +6847,7 @@ class MetaData extends SingleChildRenderObjectWidget { /// A widget that annotates the widget tree with a description of the meaning of /// the widgets. /// -/// Used by accessibility tools, search engines, and other semantic analysis +/// Used by assitive technologies, search engines, and other semantic analysis /// software to determine the meaning of the application. /// /// {@youtube 560 315 https://www.youtube.com/watch?v=NvtMt_DtFrQ} diff --git a/packages/flutter_test/lib/src/controller.dart b/packages/flutter_test/lib/src/controller.dart index 330f7100bfd66..d9eac55c80593 100644 --- a/packages/flutter_test/lib/src/controller.dart +++ b/packages/flutter_test/lib/src/controller.dart @@ -23,6 +23,202 @@ const double kDragSlopDefault = 20.0; const String _defaultPlatform = kIsWeb ? 'web' : 'android'; +/// Class that programatically interacts with the [Semantics] tree. +/// +/// Allows for testing of the [Semantics] tree, which is used by assistive +/// technology, search engines, and other analysis software to determine the +/// meaning of an application. +/// +/// Should be accessed through [WidgetController.semantics]. If no custom +/// implementation is provided, a default [SemanticsController] will be created. +class SemanticsController { + /// Creates a [SemanticsController] that uses the given binding. Will be + /// automatically created as part of instantiating a [WidgetController], but + /// a custom implementation can be passed via the [WidgetController] constructor. + SemanticsController._(WidgetsBinding binding) : _binding = binding; + + static final int _scrollingActions = + SemanticsAction.scrollUp.index | + SemanticsAction.scrollDown.index | + SemanticsAction.scrollLeft.index | + SemanticsAction.scrollRight.index; + + /// Based on Android's FOCUSABLE_FLAGS. See [flutter/engine/AccessibilityBridge.java](https://github.com/flutter/engine/blob/main/shell/platform/android/io/flutter/view/AccessibilityBridge.java). + static final int _importantFlagsForAccessibility = + SemanticsFlag.hasCheckedState.index | + SemanticsFlag.hasToggledState.index | + SemanticsFlag.hasEnabledState.index | + SemanticsFlag.isButton.index | + SemanticsFlag.isTextField.index | + SemanticsFlag.isFocusable.index | + SemanticsFlag.isSlider.index | + SemanticsFlag.isInMutuallyExclusiveGroup.index; + + final WidgetsBinding _binding; + + /// Attempts to find the [SemanticsNode] of first result from `finder`. + /// + /// If the object identified by the finder doesn't own its semantic node, + /// this will return the semantics data of the first ancestor with semantics. + /// The ancestor's semantic data will include the child's as well as + /// other nodes that have been merged together. + /// + /// If the [SemanticsNode] of the object identified by the finder is + /// force-merged into an ancestor (e.g. via the [MergeSemantics] widget) + /// the node into which it is merged is returned. That node will include + /// all the semantics information of the nodes merged into it. + /// + /// Will throw a [StateError] if the finder returns more than one element or + /// if no semantics are found or are not enabled. + SemanticsNode find(Finder finder) { + TestAsyncUtils.guardSync(); + if (_binding.pipelineOwner.semanticsOwner == null) { + throw StateError('Semantics are not enabled.'); + } + final Iterable candidates = finder.evaluate(); + if (candidates.isEmpty) { + throw StateError('Finder returned no matching elements.'); + } + if (candidates.length > 1) { + throw StateError('Finder returned more than one element.'); + } + final Element element = candidates.single; + RenderObject? renderObject = element.findRenderObject(); + SemanticsNode? result = renderObject?.debugSemantics; + while (renderObject != null && (result == null || result.isMergedIntoParent)) { + renderObject = renderObject.parent as RenderObject?; + result = renderObject?.debugSemantics; + } + if (result == null) { + throw StateError('No Semantics data found.'); + } + return result; + } + + /// Simulates a traversal of the currently visible semantics tree as if by + /// assistive technologies. + /// + /// Starts at the node for `start`. If `start` is not provided, then the + /// traversal begins with the first accessible node in the tree. If `start` + /// finds zero elements or more than one element, a [StateError] will be + /// thrown. + /// + /// Ends at the node for `end`, inclusive. If `end` is not provided, then the + /// traversal ends with the last accessible node in the currently available + /// tree. If `end` finds zero elements or more than one element, a + /// [StateError] will be thrown. + /// + /// Since the order is simulated, edge cases that differ between platforms + /// (such as how the last visible item in a scrollable list is handled) may be + /// inconsistent with platform behavior, but are expected to be sufficient for + /// testing order, availability to assistive technologies, and interactions. + /// + /// ## Sample Code + /// + /// ``` + /// testWidgets('MyWidget', (WidgetTester tester) async { + /// await tester.pumpWidget(MyWidget()); + /// + /// expect( + /// tester.semantics.simulatedAccessibilityTraversal(), + /// containsAllInOrder([ + /// containsSemantics(label: 'My Widget'), + /// containsSemantics(label: 'is awesome!', isChecked: true), + /// ]), + /// ); + /// }); + /// ``` + /// + /// See also: + /// + /// * [containsSemantics] and [matchesSemantics], which can be used to match + /// against a single node in the traversal + /// * [containsAllInOrder], which can be given an [Iterable] to fuzzy + /// match the order allowing extra nodes before after and between matching + /// parts of the traversal + /// * [orderedEquals], which can be given an [Iterable] to exactly + /// match the order of the traversal + Iterable simulatedAccessibilityTraversal({Finder? start, Finder? end}) { + TestAsyncUtils.guardSync(); + final List traversal = []; + _traverse(_binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!, traversal); + + int startIndex = 0; + int endIndex = traversal.length - 1; + + if (start != null) { + final SemanticsNode startNode = find(start); + startIndex = traversal.indexOf(startNode); + if (startIndex == -1) { + throw StateError( + 'The expected starting node was not found.\n' + 'Finder: ${start.description}\n\n' + 'Expected Start Node: $startNode\n\n' + 'Traversal: [\n ${traversal.join('\n ')}\n]'); + } + } + + if (end != null) { + final SemanticsNode endNode = find(end); + endIndex = traversal.indexOf(endNode); + if (endIndex == -1) { + throw StateError( + 'The expected ending node was not found.\n' + 'Finder: ${end.description}\n\n' + 'Expected End Node: $endNode\n\n' + 'Traversal: [\n ${traversal.join('\n ')}\n]'); + } + } + + return traversal.getRange(startIndex, endIndex + 1); + } + + /// Recursive depth first traversal of the specified `node`, adding nodes + /// that are important for semantics to the `traversal` list. + void _traverse(SemanticsNode node, List traversal){ + if (_isImportantForAccessibility(node)) { + traversal.add(node); + } + + final List children = node.debugListChildrenInOrder(DebugSemanticsDumpOrder.traversalOrder); + for (final SemanticsNode child in children) { + _traverse(child, traversal); + } + } + + /// Whether or not the node is important for semantics. Should match most cases + /// on the platforms, but certain edge cases will be inconsisent. + /// + /// Based on: + /// + /// * [flutter/engine/AccessibilityBridge.java#SemanticsNode.isFocusable()](https://github.com/flutter/engine/blob/main/shell/platform/android/io/flutter/view/AccessibilityBridge.java#L2641) + /// * [flutter/engine/SemanticsObject.mm#SemanticsObject.isAccessibilityElement](https://github.com/flutter/engine/blob/main/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm#L449) + bool _isImportantForAccessibility(SemanticsNode node) { + // If the node scopes a route, it doesn't matter what other flags/actions it + // has, it is _not_ important for accessibility, so we short circuit. + if (node.hasFlag(SemanticsFlag.scopesRoute)) { + return false; + } + + final bool hasNonScrollingAction = node.getSemanticsData().actions & ~_scrollingActions != 0; + if (hasNonScrollingAction) { + return true; + } + + final bool hasImportantFlag = node.getSemanticsData().flags & _importantFlagsForAccessibility != 0; + if (hasImportantFlag) { + return true; + } + + final bool hasContent = node.label.isNotEmpty || node.value.isNotEmpty || node.hint.isNotEmpty; + if (hasContent) { + return true; + } + + return false; + } +} + /// Class that programmatically interacts with widgets. /// /// For a variant of this class suited specifically for unit tests, see @@ -32,11 +228,30 @@ const String _defaultPlatform = kIsWeb ? 'web' : 'android'; /// Concrete subclasses must implement the [pump] method. abstract class WidgetController { /// Creates a widget controller that uses the given binding. - WidgetController(this.binding); + WidgetController(this.binding) + : _semantics = SemanticsController._(binding); /// A reference to the current instance of the binding. final WidgetsBinding binding; + /// Provides access to a [SemanticsController] for testing anything related to + /// the [Semantics] tree. + /// + /// Assistive technologies, search engines, and other analysis tools all make + /// use of the [Semantics] tree to determine the meaning of an application. + /// If semantics has been disabled for the test, this will throw a [StateError]. + SemanticsController get semantics { + if (binding.pipelineOwner.semanticsOwner == null) { + throw StateError( + 'Semantics are not enabled. Enable them by passing ' + '`semanticsEnabled: true` to `testWidgets`, or by manually creating a ' + '`SemanticsHandle` with `WidgetController.ensureSemantics()`.'); + } + + return _semantics; + } + final SemanticsController _semantics; + // FINDER API // TODO(ianh): verify that the return values are of type T and throw @@ -1257,29 +1472,8 @@ abstract class WidgetController { /// /// Will throw a [StateError] if the finder returns more than one element or /// if no semantics are found or are not enabled. - SemanticsNode getSemantics(Finder finder) { - if (binding.pipelineOwner.semanticsOwner == null) { - throw StateError('Semantics are not enabled.'); - } - final Iterable candidates = finder.evaluate(); - if (candidates.isEmpty) { - throw StateError('Finder returned no matching elements.'); - } - if (candidates.length > 1) { - throw StateError('Finder returned more than one element.'); - } - final Element element = candidates.single; - RenderObject? renderObject = element.findRenderObject(); - SemanticsNode? result = renderObject?.debugSemantics; - while (renderObject != null && (result == null || result.isMergedIntoParent)) { - renderObject = renderObject.parent as RenderObject?; - result = renderObject?.debugSemantics; - } - if (result == null) { - throw StateError('No Semantics data found.'); - } - return result; - } + // TODO(pdblasi-google): Deprecate this and point references to semantics.find. See https://github.com/flutter/flutter/issues/112670. + SemanticsNode getSemantics(Finder finder) => semantics.find(finder); /// Enable semantics in a test by creating a [SemanticsHandle]. /// diff --git a/packages/flutter_test/test/controller_test.dart b/packages/flutter_test/test/controller_test.dart index f8e95728c71b0..c057a5099a2cc 100644 --- a/packages/flutter_test/test/controller_test.dart +++ b/packages/flutter_test/test/controller_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:collection/collection.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; @@ -20,136 +21,6 @@ class TestDragData { } void main() { - group('getSemanticsData', () { - testWidgets('throws when there are no semantics', (WidgetTester tester) async { - await tester.pumpWidget( - const MaterialApp( - home: Scaffold( - body: Text('hello'), - ), - ), - ); - - expect(() => tester.getSemantics(find.text('hello')), throwsStateError); - }, semanticsEnabled: false); - - testWidgets('throws when there are multiple results from the finder', (WidgetTester tester) async { - final SemanticsHandle semanticsHandle = tester.ensureSemantics(); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Row( - children: const [ - Text('hello'), - Text('hello'), - ], - ), - ), - ), - ); - - expect(() => tester.getSemantics(find.text('hello')), throwsStateError); - semanticsHandle.dispose(); - }); - - testWidgets('Returns the correct SemanticsData', (WidgetTester tester) async { - final SemanticsHandle semanticsHandle = tester.ensureSemantics(); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: OutlinedButton( - onPressed: () { }, - child: const Text('hello'), - ), - ), - ), - ); - - final SemanticsNode node = tester.getSemantics(find.text('hello')); - final SemanticsData semantics = node.getSemanticsData(); - expect(semantics.label, 'hello'); - expect(semantics.hasAction(SemanticsAction.tap), true); - expect(semantics.hasFlag(SemanticsFlag.isButton), true); - semanticsHandle.dispose(); - }); - - testWidgets('Can enable semantics for tests via semanticsEnabled', (WidgetTester tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: OutlinedButton( - onPressed: () { }, - child: const Text('hello'), - ), - ), - ), - ); - - final SemanticsNode node = tester.getSemantics(find.text('hello')); - final SemanticsData semantics = node.getSemanticsData(); - expect(semantics.label, 'hello'); - expect(semantics.hasAction(SemanticsAction.tap), true); - expect(semantics.hasFlag(SemanticsFlag.isButton), true); - }); - - testWidgets('Returns merged SemanticsData', (WidgetTester tester) async { - final SemanticsHandle semanticsHandle = tester.ensureSemantics(); - const Key key = Key('test'); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Semantics( - label: 'A', - child: Semantics( - label: 'B', - child: Semantics( - key: key, - label: 'C', - child: Container(), - ), - ), - ), - ), - ), - ); - - final SemanticsNode node = tester.getSemantics(find.byKey(key)); - final SemanticsData semantics = node.getSemanticsData(); - expect(semantics.label, 'A\nB\nC'); - semanticsHandle.dispose(); - }); - - testWidgets('Does not return partial semantics', (WidgetTester tester) async { - final SemanticsHandle semanticsHandle = tester.ensureSemantics(); - final Key key = UniqueKey(); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: MergeSemantics( - child: Semantics( - container: true, - label: 'A', - child: Semantics( - container: true, - key: key, - label: 'B', - child: Container(), - ), - ), - ), - ), - ), - ); - - final SemanticsNode node = tester.getSemantics(find.byKey(key)); - final SemanticsData semantics = node.getSemanticsData(); - expect(semantics.label, 'A\nB'); - semanticsHandle.dispose(); - }); - }); - testWidgets( 'WidgetTester.drag must break the offset into multiple parallel components if ' 'the drag goes outside the touch slop values', @@ -805,4 +676,308 @@ void main() { expect(find.text('Item b-45'), findsOneWidget); }); }); + + group('SemanticsController', () { + group('find', () { + testWidgets('throws when there are no semantics', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Text('hello'), + ), + ), + ); + + expect(() => tester.semantics.find(find.text('hello')), throwsStateError); + }, semanticsEnabled: false); + + testWidgets('throws when there are multiple results from the finder', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Row( + children: const [ + Text('hello'), + Text('hello'), + ], + ), + ), + ), + ); + + expect(() => tester.semantics.find(find.text('hello')), throwsStateError); + }); + + testWidgets('Returns the correct SemanticsData', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: OutlinedButton( + onPressed: () { }, + child: const Text('hello'), + ), + ), + ), + ); + + final SemanticsNode node = tester.semantics.find(find.text('hello')); + final SemanticsData semantics = node.getSemanticsData(); + expect(semantics.label, 'hello'); + expect(semantics.hasAction(SemanticsAction.tap), true); + expect(semantics.hasFlag(SemanticsFlag.isButton), true); + }); + + testWidgets('Can enable semantics for tests via semanticsEnabled', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: OutlinedButton( + onPressed: () { }, + child: const Text('hello'), + ), + ), + ), + ); + + final SemanticsNode node = tester.semantics.find(find.text('hello')); + final SemanticsData semantics = node.getSemanticsData(); + expect(semantics.label, 'hello'); + expect(semantics.hasAction(SemanticsAction.tap), true); + expect(semantics.hasFlag(SemanticsFlag.isButton), true); + }); + + testWidgets('Returns merged SemanticsData', (WidgetTester tester) async { + const Key key = Key('test'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Semantics( + label: 'A', + child: Semantics( + label: 'B', + child: Semantics( + key: key, + label: 'C', + child: Container(), + ), + ), + ), + ), + ), + ); + + final SemanticsNode node = tester.semantics.find(find.byKey(key)); + final SemanticsData semantics = node.getSemanticsData(); + expect(semantics.label, 'A\nB\nC'); + }); + + testWidgets('Does not return partial semantics', (WidgetTester tester) async { + final Key key = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MergeSemantics( + child: Semantics( + container: true, + label: 'A', + child: Semantics( + container: true, + key: key, + label: 'B', + child: Container(), + ), + ), + ), + ), + ), + ); + + final SemanticsNode node = tester.semantics.find(find.byKey(key)); + final SemanticsData semantics = node.getSemanticsData(); + expect(semantics.label, 'A\nB'); + }); + }); + + group('simulatedTraversal', () { + final List fullTraversalMatchers = [ + containsSemantics(isHeader: true, label: 'Semantics Test'), + containsSemantics(isTextField: true), + containsSemantics(label: 'Off Switch'), + containsSemantics(hasToggledState: true), + containsSemantics(label: 'On Switch'), + containsSemantics(hasToggledState: true, isToggled: true), + containsSemantics(label: "Multiline\nIt's a\nmultiline label!"), + containsSemantics(label: 'Slider'), + containsSemantics(isSlider: true, value: '50%'), + containsSemantics(label: 'Enabled Button'), + containsSemantics(isButton: true, label: 'Tap'), + containsSemantics(label: 'Disabled Button'), + containsSemantics(isButton: true, label: "Don't Tap"), + containsSemantics(label: 'Checked Radio'), + containsSemantics(hasCheckedState: true, isChecked: true), + containsSemantics(label: 'Unchecked Radio'), + containsSemantics(hasCheckedState: true, isChecked: false), + ]; + + testWidgets('produces expected traversal', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: _SemanticsTestWidget())); + + expect( + tester.semantics.simulatedAccessibilityTraversal(), + orderedEquals(fullTraversalMatchers)); + }); + + testWidgets('starts traversal at semantics node for `start`', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: _SemanticsTestWidget())); + + // We're expecting the traversal to start where the slider is. + final List expectedMatchers = [...fullTraversalMatchers]..removeRange(0, 8); + + expect( + tester.semantics.simulatedAccessibilityTraversal(start: find.byType(Slider)), + orderedEquals(expectedMatchers)); + }); + + testWidgets('throws StateError if `start` not found in traversal', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: _SemanticsTestWidget())); + + // We look for a SingleChildScrollView since the view itself isn't + // important for accessiblity, so it won't show up in the traversal + expect( + () => tester.semantics.simulatedAccessibilityTraversal(start: find.byType(SingleChildScrollView)), + throwsA(isA()), + ); + }); + + testWidgets('ends traversal at semantics node for `end`', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: _SemanticsTestWidget())); + + // We're expecting the traversal to end where the slider is, inclusive. + final Iterable expectedMatchers = [...fullTraversalMatchers].getRange(0, 9); + + expect( + tester.semantics.simulatedAccessibilityTraversal(end: find.byType(Slider)), + orderedEquals(expectedMatchers)); + }); + + testWidgets('throws StateError if `end` not found in traversal', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: _SemanticsTestWidget())); + + // We look for a SingleChildScrollView since the view itself isn't + // important for semantics, so it won't show up in the traversal + expect( + () => tester.semantics.simulatedAccessibilityTraversal(end: find.byType(SingleChildScrollView)), + throwsA(isA()), + ); + }); + + testWidgets('returns traversal between `start` and `end` if both are provided', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: _SemanticsTestWidget())); + + // We're expecting the traversal to start at the text field and end at the slider. + final Iterable expectedMatchers = [...fullTraversalMatchers].getRange(1, 9); + + expect( + tester.semantics.simulatedAccessibilityTraversal( + start: find.byType(TextField), + end: find.byType(Slider), + ), + orderedEquals(expectedMatchers)); + }); + + testWidgets('can do fuzzy traversal match with `containsAllInOrder`', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: _SemanticsTestWidget())); + + // Grab a sample of the matchers to validate that not every matcher is + // needed to validate a traversal when using `containsAllInOrder`. + final Iterable expectedMatchers = [...fullTraversalMatchers] + ..removeAt(0) + ..removeLast() + ..mapIndexed((int i, Matcher element) => i.isEven ? element : null) + .whereNotNull(); + + expect( + tester.semantics.simulatedAccessibilityTraversal(), + containsAllInOrder(expectedMatchers)); + }); + }); + }); +} + +class _SemanticsTestWidget extends StatelessWidget { + const _SemanticsTestWidget(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Semantics Test')), + body: SingleChildScrollView( + child: Column( + children: [ + const _SemanticsTestCard( + label: 'TextField', + widget: TextField(), + ), + _SemanticsTestCard( + label: 'Off Switch', + widget: Switch(value: false, onChanged: (bool value) {}), + ), + _SemanticsTestCard( + label: 'On Switch', + widget: Switch(value: true, onChanged: (bool value) {}), + ), + const _SemanticsTestCard( + label: 'Multiline', + widget: Text("It's a\nmultiline label!", maxLines: 2), + ), + _SemanticsTestCard( + label: 'Slider', + widget: Slider(value: .5, onChanged: (double value) {}), + ), + _SemanticsTestCard( + label: 'Enabled Button', + widget: TextButton(onPressed: () {}, child: const Text('Tap')), + ), + const _SemanticsTestCard( + label: 'Disabled Button', + widget: TextButton(onPressed: null, child: Text("Don't Tap")), + ), + _SemanticsTestCard( + label: 'Checked Radio', + widget: Radio( + value: 'checked', + groupValue: 'checked', + onChanged: (String? value) {}, + ), + ), + _SemanticsTestCard( + label: 'Unchecked Radio', + widget: Radio( + value: 'unchecked', + groupValue: 'checked', + onChanged: (String? value) {}, + ), + ), + ], + ), + ), + ); + } +} + +class _SemanticsTestCard extends StatelessWidget { + const _SemanticsTestCard({required this.label, required this.widget}); + + final String label; + final Widget widget; + + @override + Widget build(BuildContext context) { + return Card( + child: ListTile( + title: Text(label), + trailing: SizedBox(width: 200, child: widget), + ), + ); + } }