| // Copyright 2014 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:async'; |
| import 'dart:convert'; |
| |
| import 'package:android_semantics_testing/android_semantics_testing.dart'; |
| import 'package:android_semantics_testing/main.dart' as app; |
| import 'package:android_semantics_testing/test_constants.dart'; |
| import 'package:flutter/scheduler.dart'; |
| import 'package:flutter/services.dart'; |
| import 'package:flutter/widgets.dart'; |
| import 'package:flutter_test/flutter_test.dart'; |
| import 'package:integration_test/integration_test.dart'; |
| |
| // The accessibility focus actions are added when a semantics node receives or |
| // lose accessibility focus. This test ignores these actions since it is hard to |
| // predict which node has the accessibility focus after a screen changes. |
| const List<AndroidSemanticsAction> ignoredAccessibilityFocusActions = <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.accessibilityFocus, |
| AndroidSemanticsAction.clearAccessibilityFocus, |
| ]; |
| |
| const MethodChannel kSemanticsChannel = MethodChannel('semantics'); |
| |
| Future<void> setClipboard(String message) async { |
| final Completer<void> completer = Completer<void>(); |
| Future<void> completeSetClipboard([Object? _]) async { |
| await kSemanticsChannel.invokeMethod<dynamic>('setClipboard', <String, dynamic>{ |
| 'message': message, |
| }); |
| completer.complete(); |
| } |
| if (SchedulerBinding.instance.hasScheduledFrame) { |
| SchedulerBinding.instance.addPostFrameCallback(completeSetClipboard); |
| } else { |
| completeSetClipboard(); |
| } |
| await completer.future; |
| } |
| |
| Future<AndroidSemanticsNode> getSemantics(Finder finder, WidgetTester tester) async { |
| final int id = tester.getSemantics(finder).id; |
| final Completer<String> completer = Completer<String>(); |
| Future<void> completeSemantics([Object? _]) async { |
| final dynamic result = await kSemanticsChannel.invokeMethod<dynamic>('getSemanticsNode', <String, dynamic>{ |
| 'id': id, |
| }); |
| completer.complete(json.encode(result)); |
| } |
| if (SchedulerBinding.instance.hasScheduledFrame) { |
| SchedulerBinding.instance.addPostFrameCallback(completeSemantics); |
| } else { |
| completeSemantics(); |
| } |
| return AndroidSemanticsNode.deserialize(await completer.future); |
| } |
| |
| Future<void> main() async { |
| IntegrationTestWidgetsFlutterBinding.ensureInitialized(); |
| |
| group('AccessibilityBridge', () { |
| group('TextField', () { |
| Future<void> prepareTextField(WidgetTester tester) async { |
| app.main(); |
| await tester.pumpAndSettle(); |
| await tester.tap(find.text(textFieldRoute)); |
| await tester.pumpAndSettle(); |
| |
| // The text selection menu and related semantics vary depending on if |
| // the clipboard contents are pasteable. Copy some text into the |
| // clipboard to make sure these tests always run with pasteable content |
| // in the clipboard. |
| // Ideally this should test the case where there is nothing on the |
| // clipboard as well, but there is no reliable way to clear the |
| // clipboard on Android devices. |
| await setClipboard('Hello World'); |
| await tester.pumpAndSettle(); |
| } |
| |
| testWidgets('TextField has correct Android semantics', (WidgetTester tester) async { |
| final Finder normalTextField = find.descendant( |
| of: find.byKey(const ValueKey<String>(normalTextFieldKeyValue)), |
| matching: find.byType(EditableText), |
| ); |
| |
| await prepareTextField(tester); |
| expect( |
| await getSemantics(normalTextField, tester), |
| hasAndroidSemantics( |
| className: AndroidClassName.editText, |
| isEditable: true, |
| isFocusable: true, |
| isFocused: false, |
| isPassword: false, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| // We can't predict the a11y focus when the screen changes. |
| ignoredActions: ignoredAccessibilityFocusActions, |
| ), |
| ); |
| await tester.tap(normalTextField); |
| await tester.pumpAndSettle(); |
| |
| expect( |
| await getSemantics(normalTextField, tester), |
| hasAndroidSemantics( |
| className: AndroidClassName.editText, |
| isFocusable: true, |
| isFocused: true, |
| isEditable: true, |
| isPassword: false, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| AndroidSemanticsAction.paste, |
| AndroidSemanticsAction.setSelection, |
| AndroidSemanticsAction.setText, |
| ], |
| // We can't predict the a11y focus when the screen changes. |
| ignoredActions: ignoredAccessibilityFocusActions, |
| ), |
| ); |
| |
| await tester.enterText(normalTextField, 'hello world'); |
| await tester.pumpAndSettle(); |
| |
| expect( |
| await getSemantics(normalTextField, tester), |
| hasAndroidSemantics( |
| text: 'hello world', |
| className: AndroidClassName.editText, |
| isFocusable: true, |
| isFocused: true, |
| isEditable: true, |
| isPassword: false, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| AndroidSemanticsAction.paste, |
| AndroidSemanticsAction.setSelection, |
| AndroidSemanticsAction.setText, |
| AndroidSemanticsAction.previousAtMovementGranularity, |
| ], |
| // We can't predict the a11y focus when the screen changes. |
| ignoredActions: ignoredAccessibilityFocusActions, |
| ), |
| ); |
| }, timeout: Timeout.none); |
| |
| testWidgets('password TextField has correct Android semantics', (WidgetTester tester) async { |
| final Finder passwordTextField = find.descendant( |
| of: find.byKey(const ValueKey<String>(passwordTextFieldKeyValue)), |
| matching: find.byType(EditableText), |
| ); |
| |
| await prepareTextField(tester); |
| expect( |
| await getSemantics(passwordTextField, tester), |
| hasAndroidSemantics( |
| className: AndroidClassName.editText, |
| isEditable: true, |
| isFocusable: true, |
| isFocused: false, |
| isPassword: true, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| // We can't predict the a11y focus when the screen changes. |
| ignoredActions: ignoredAccessibilityFocusActions, |
| ), |
| ); |
| |
| await tester.tap(passwordTextField); |
| await tester.pumpAndSettle(); |
| |
| expect( |
| await getSemantics(passwordTextField, tester), |
| hasAndroidSemantics( |
| className: AndroidClassName.editText, |
| isFocusable: true, |
| isFocused: true, |
| isEditable: true, |
| isPassword: true, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| AndroidSemanticsAction.paste, |
| AndroidSemanticsAction.setSelection, |
| AndroidSemanticsAction.setText, |
| ], |
| // We can't predict the a11y focus when the screen changes. |
| ignoredActions: ignoredAccessibilityFocusActions, |
| ), |
| ); |
| |
| await tester.enterText(passwordTextField, 'hello world'); |
| await tester.pumpAndSettle(); |
| |
| expect( |
| await getSemantics(passwordTextField, tester), |
| hasAndroidSemantics( |
| text: '\u{2022}' * ('hello world'.length), |
| className: AndroidClassName.editText, |
| isFocusable: true, |
| isFocused: true, |
| isEditable: true, |
| isPassword: true, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| AndroidSemanticsAction.paste, |
| AndroidSemanticsAction.setSelection, |
| AndroidSemanticsAction.setText, |
| AndroidSemanticsAction.previousAtMovementGranularity, |
| ], |
| // We can't predict the a11y focus when the screen changes. |
| ignoredActions: ignoredAccessibilityFocusActions, |
| ), |
| ); |
| }, timeout: Timeout.none); |
| }); |
| |
| group('SelectionControls', () { |
| Future<void> prepareSelectionControls(WidgetTester tester) async { |
| app.main(); |
| await tester.pumpAndSettle(); |
| await tester.tap(find.text(selectionControlsRoute)); |
| await tester.pumpAndSettle(); |
| } |
| |
| testWidgets('Checkbox has correct Android semantics', (WidgetTester tester) async { |
| final Finder checkbox = find.byKey(const ValueKey<String>(checkboxKeyValue)); |
| final Finder disabledCheckbox = find.byKey(const ValueKey<String>(disabledCheckboxKeyValue)); |
| |
| await prepareSelectionControls(tester); |
| expect( |
| await getSemantics(checkbox, tester), |
| hasAndroidSemantics( |
| className: AndroidClassName.checkBox, |
| isChecked: false, |
| isCheckable: true, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| ); |
| |
| await tester.tap(checkbox); |
| await tester.pumpAndSettle(); |
| |
| expect( |
| await getSemantics(checkbox, tester), |
| hasAndroidSemantics( |
| className: AndroidClassName.checkBox, |
| isChecked: true, |
| isCheckable: true, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| ); |
| expect( |
| await getSemantics(disabledCheckbox, tester), |
| hasAndroidSemantics( |
| className: AndroidClassName.checkBox, |
| isCheckable: true, |
| isEnabled: false, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: const <AndroidSemanticsAction>[], |
| ), |
| ); |
| }, timeout: Timeout.none); |
| |
| testWidgets('Radio has correct Android semantics', (WidgetTester tester) async { |
| final Finder radio = find.byKey(const ValueKey<String>(radio2KeyValue)); |
| |
| await prepareSelectionControls(tester); |
| expect( |
| await getSemantics(radio, tester), |
| hasAndroidSemantics( |
| className: AndroidClassName.radio, |
| isChecked: false, |
| isCheckable: true, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| ); |
| |
| await tester.tap(radio); |
| await tester.pumpAndSettle(); |
| |
| expect( |
| await getSemantics(radio, tester), |
| hasAndroidSemantics( |
| className: AndroidClassName.radio, |
| isChecked: true, |
| isCheckable: true, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| ); |
| }, timeout: Timeout.none); |
| |
| testWidgets('Switch has correct Android semantics', (WidgetTester tester) async { |
| final Finder switchFinder = find.byKey(const ValueKey<String>(switchKeyValue)); |
| |
| await prepareSelectionControls(tester); |
| expect( |
| await getSemantics(switchFinder, tester), |
| hasAndroidSemantics( |
| className: AndroidClassName.toggleSwitch, |
| isChecked: false, |
| isCheckable: true, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| ); |
| |
| await tester.tap(switchFinder); |
| await tester.pumpAndSettle(); |
| |
| expect( |
| await getSemantics(switchFinder, tester), |
| hasAndroidSemantics( |
| className: AndroidClassName.toggleSwitch, |
| isChecked: true, |
| isCheckable: true, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| ); |
| }, timeout: Timeout.none); |
| |
| // Regression test for https://github.com/flutter/flutter/issues/20820. |
| testWidgets('Switch can be labeled', (WidgetTester tester) async { |
| final Finder switchFinder = find.byKey(const ValueKey<String>(labeledSwitchKeyValue)); |
| |
| await prepareSelectionControls(tester); |
| expect( |
| await getSemantics(switchFinder, tester), |
| hasAndroidSemantics( |
| className: AndroidClassName.toggleSwitch, |
| isChecked: false, |
| isCheckable: true, |
| isEnabled: true, |
| isFocusable: true, |
| contentDescription: switchLabel, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| ); |
| }, timeout: Timeout.none); |
| }); |
| |
| group('Popup Controls', () { |
| Future<void> preparePopupControls(WidgetTester tester) async { |
| app.main(); |
| await tester.pumpAndSettle(); |
| await tester.tap(find.text(popupControlsRoute)); |
| await tester.pumpAndSettle(); |
| } |
| |
| testWidgets('Popup Menu has correct Android semantics', (WidgetTester tester) async { |
| final Finder popupButton = find.byKey(const ValueKey<String>(popupButtonKeyValue)); |
| |
| await preparePopupControls(tester); |
| expect( |
| await getSemantics(popupButton, tester), |
| hasAndroidSemantics( |
| className: AndroidClassName.button, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| ); |
| |
| await tester.tap(popupButton); |
| await tester.pumpAndSettle(); |
| |
| try { |
| for (final String item in popupItems) { |
| expect( |
| await getSemantics(find.byKey(ValueKey<String>('$popupKeyValue.$item')), tester), |
| hasAndroidSemantics( |
| className: AndroidClassName.button, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| reason: "Popup $item doesn't have the right semantics", |
| ); |
| } |
| await tester.tap(find.byKey(ValueKey<String>('$popupKeyValue.${popupItems.first}'))); |
| await tester.pumpAndSettle(); |
| |
| // Pop up the menu again, to verify that TalkBack gets the right answer |
| // more than just the first time. |
| await tester.tap(popupButton); |
| await tester.pumpAndSettle(); |
| |
| for (final String item in popupItems) { |
| expect( |
| await getSemantics(find.byKey(ValueKey<String>('$popupKeyValue.$item')), tester), |
| hasAndroidSemantics( |
| className: AndroidClassName.button, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| reason: "Popup $item doesn't have the right semantics the second time", |
| ); |
| } |
| } finally { |
| await tester.tap(find.byKey(ValueKey<String>('$popupKeyValue.${popupItems.first}'))); |
| } |
| }, timeout: Timeout.none); |
| |
| testWidgets('Dropdown Menu has correct Android semantics', (WidgetTester tester) async { |
| final Finder dropdownButton = find.byKey(const ValueKey<String>(dropdownButtonKeyValue)); |
| |
| await preparePopupControls(tester); |
| expect( |
| await getSemantics(dropdownButton, tester), |
| hasAndroidSemantics( |
| className: AndroidClassName.button, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| ); |
| |
| await tester.tap(dropdownButton); |
| await tester.pumpAndSettle(); |
| |
| try { |
| for (final String item in popupItems) { |
| // There are two copies of each item, so we want to find the version |
| // that is in the overlay, not the one in the dropdown. |
| expect( |
| await getSemantics( |
| find.descendant( |
| of: find.byType(Scrollable), |
| matching: find.byKey(ValueKey<String>('$dropdownKeyValue.$item')), |
| ), |
| tester, |
| ), |
| hasAndroidSemantics( |
| className: AndroidClassName.view, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| reason: "Dropdown $item doesn't have the right semantics", |
| ); |
| } |
| await tester.tap( |
| find.descendant( |
| of: find.byType(Scrollable), |
| matching: find.byKey(ValueKey<String>('$dropdownKeyValue.${popupItems.first}')), |
| ), |
| ); |
| await tester.pumpAndSettle(); |
| |
| // Pop up the dropdown again, to verify that TalkBack gets the right answer |
| // more than just the first time. |
| await tester.tap(dropdownButton); |
| await tester.pumpAndSettle(); |
| |
| for (final String item in popupItems) { |
| // There are two copies of each item, so we want to find the version |
| // that is in the overlay, not the one in the dropdown. |
| expect( |
| await getSemantics( |
| find.descendant( |
| of: find.byType(Scrollable), |
| matching: find.byKey(ValueKey<String>('$dropdownKeyValue.$item')), |
| ), |
| tester, |
| ), |
| hasAndroidSemantics( |
| className: AndroidClassName.view, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| reason: "Dropdown $item doesn't have the right semantics the second time.", |
| ); |
| } |
| } finally { |
| await tester.tap( |
| find.descendant( |
| of: find.byType(Scrollable), |
| matching: find.byKey(ValueKey<String>('$dropdownKeyValue.${popupItems.first}')), |
| ), |
| ); |
| } |
| }, timeout: Timeout.none); |
| |
| testWidgets('Modal alert dialog has correct Android semantics', (WidgetTester tester) async { |
| final Finder alertButton = find.byKey(const ValueKey<String>(alertButtonKeyValue)); |
| |
| await preparePopupControls(tester); |
| expect( |
| await getSemantics(alertButton, tester), |
| hasAndroidSemantics( |
| className: AndroidClassName.button, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| ); |
| |
| await tester.tap(alertButton); |
| await tester.pumpAndSettle(); |
| |
| try { |
| expect( |
| await getSemantics(find.byKey(const ValueKey<String>('$alertKeyValue.OK')), tester), |
| hasAndroidSemantics( |
| className: AndroidClassName.button, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| reason: "Alert OK button doesn't have the right semantics", |
| ); |
| |
| for (final String item in <String>['Title', 'Body1', 'Body2']) { |
| expect( |
| await getSemantics(find.byKey(ValueKey<String>('$alertKeyValue.$item')), tester), |
| hasAndroidSemantics( |
| className: AndroidClassName.view, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[], |
| ), |
| reason: "Alert $item button doesn't have the right semantics", |
| ); |
| } |
| |
| await tester.tap(find.byKey(const ValueKey<String>('$alertKeyValue.OK'))); |
| await tester.pumpAndSettle(); |
| |
| // Pop up the alert again, to verify that TalkBack gets the right answer |
| // more than just the first time. |
| await tester.tap(alertButton); |
| await tester.pumpAndSettle(); |
| |
| expect( |
| await getSemantics(find.byKey(const ValueKey<String>('$alertKeyValue.OK')), tester), |
| hasAndroidSemantics( |
| className: AndroidClassName.button, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[ |
| AndroidSemanticsAction.click, |
| ], |
| ), |
| reason: "Alert OK button doesn't have the right semantics", |
| ); |
| |
| for (final String item in <String>['Title', 'Body1', 'Body2']) { |
| expect( |
| await getSemantics(find.byKey(ValueKey<String>('$alertKeyValue.$item')), tester), |
| hasAndroidSemantics( |
| className: AndroidClassName.view, |
| isChecked: false, |
| isCheckable: false, |
| isEnabled: true, |
| isFocusable: true, |
| ignoredActions: ignoredAccessibilityFocusActions, |
| actions: <AndroidSemanticsAction>[], |
| ), |
| reason: "Alert $item button doesn't have the right semantics", |
| ); |
| } |
| } finally { |
| await tester.tap(find.byKey(const ValueKey<String>('$alertKeyValue.OK'))); |
| } |
| }, timeout: Timeout.none); |
| }); |
| |
| group('Headings', () { |
| Future<void> prepareHeading(WidgetTester tester) async { |
| app.main(); |
| await tester.pumpAndSettle(); |
| await tester.tap(find.text(headingsRoute)); |
| await tester.pumpAndSettle(); |
| } |
| |
| testWidgets('AppBar title has correct Android heading semantics', (WidgetTester tester) async { |
| await prepareHeading(tester); |
| expect( |
| await getSemantics(find.byKey(const ValueKey<String>(appBarTitleKeyValue)), tester), |
| hasAndroidSemantics(isHeading: true), |
| ); |
| }, timeout: Timeout.none); |
| |
| testWidgets('body text does not have Android heading semantics', (WidgetTester tester) async { |
| await prepareHeading(tester); |
| expect( |
| await getSemantics(find.byKey(const ValueKey<String>(bodyTextKeyValue)), tester), |
| hasAndroidSemantics(isHeading: false), |
| ); |
| }, timeout: Timeout.none); |
| }); |
| }); |
| } |