Skip to content

Commit 235a325

Browse files
authored
Provide test API for accessibility announcements (#109661)
1 parent 609b8f3 commit 235a325

File tree

3 files changed

+166
-0
lines changed

3 files changed

+166
-0
lines changed

packages/flutter_test/lib/src/binding.dart

+86
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ enum EnginePhase {
6262
sendSemanticsUpdate,
6363
}
6464

65+
/// Signature of callbacks used to intercept messages on a given channel.
66+
///
67+
/// See [TestDefaultBinaryMessenger.setMockDecodedMessageHandler] for more details.
68+
typedef _MockMessageHandler = Future<void> Function(Object?);
69+
6570
/// Parts of the system that can generate pointer events that reach the test
6671
/// binding.
6772
///
@@ -106,6 +111,32 @@ mixin TestDefaultBinaryMessengerBinding on BindingBase, ServicesBinding {
106111
}
107112
}
108113

114+
/// Accessibility announcement data passed to [SemanticsService.announce] captured in a test.
115+
///
116+
/// This class is intended to be used by the testing API to store the announcements
117+
/// in a structured form so that tests can verify announcement details. The fields
118+
/// of this class correspond to parameters of the [SemanticsService.announce] method.
119+
///
120+
/// See also:
121+
///
122+
/// * [WidgetTester.takeAnnouncements], which is the test API that uses this class.
123+
class CapturedAccessibilityAnnouncement {
124+
const CapturedAccessibilityAnnouncement._(
125+
this.message,
126+
this.textDirection,
127+
this.assertiveness,
128+
);
129+
130+
/// The accessibility message announced by the framework.
131+
final String message;
132+
133+
/// The direction in which the text of the [message] flows.
134+
final TextDirection textDirection;
135+
136+
/// Determines the assertiveness level of the accessibility announcement.
137+
final Assertiveness assertiveness;
138+
}
139+
109140
/// Base class for bindings used by widgets library tests.
110141
///
111142
/// The [ensureInitialized] method creates (if necessary) and returns an
@@ -611,6 +642,24 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
611642
late StackTraceDemangler _oldStackTraceDemangler;
612643
FlutterErrorDetails? _pendingExceptionDetails;
613644

645+
_MockMessageHandler? _announcementHandler;
646+
List<CapturedAccessibilityAnnouncement> _announcements =
647+
<CapturedAccessibilityAnnouncement>[];
648+
649+
/// {@template flutter.flutter_test.TakeAccessibilityAnnouncements}
650+
/// Returns a list of all the accessibility announcements made by the Flutter
651+
/// framework since the last time this function was called.
652+
///
653+
/// It's safe to call this when there hasn't been any announcements; it will return
654+
/// an empty list in that case.
655+
/// {@endtemplate}
656+
List<CapturedAccessibilityAnnouncement> takeAnnouncements() {
657+
assert(inTest);
658+
final List<CapturedAccessibilityAnnouncement> announcements = _announcements;
659+
_announcements = <CapturedAccessibilityAnnouncement>[];
660+
return announcements;
661+
}
662+
614663
static const TextStyle _messageStyle = TextStyle(
615664
color: Color(0xFF917FFF),
616665
fontSize: 40.0,
@@ -700,13 +749,41 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
700749
// The LiveTestWidgetsFlutterBinding overrides this to report the exception to the console.
701750
}
702751

752+
Future<void> _handleAnnouncementMessage(Object? mockMessage) async {
753+
final Map<Object?, Object?> message = mockMessage! as Map<Object?, Object?>;
754+
if (message['type'] == 'announce') {
755+
final Map<Object?, Object?> data =
756+
message['data']! as Map<Object?, Object?>;
757+
final String dataMessage = data['message'].toString();
758+
final TextDirection textDirection =
759+
TextDirection.values[data['textDirection']! as int];
760+
final int assertivenessLevel = (data['assertiveness'] as int?) ?? 0;
761+
final Assertiveness assertiveness =
762+
Assertiveness.values[assertivenessLevel];
763+
final CapturedAccessibilityAnnouncement announcement =
764+
CapturedAccessibilityAnnouncement._(
765+
dataMessage, textDirection, assertiveness);
766+
_announcements.add(announcement);
767+
}
768+
}
769+
703770
Future<void> _runTest(
704771
Future<void> Function() testBody,
705772
VoidCallback invariantTester,
706773
String description,
707774
) {
708775
assert(description != null);
709776
assert(inTest);
777+
778+
// Set the handler only if there is currently none.
779+
if (TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger
780+
.checkMockMessageHandler(SystemChannels.accessibility.name, null)) {
781+
_announcementHandler = _handleAnnouncementMessage;
782+
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger
783+
.setMockDecodedMessageHandler<dynamic>(
784+
SystemChannels.accessibility, _announcementHandler);
785+
}
786+
710787
_oldExceptionHandler = FlutterError.onError;
711788
_oldStackTraceDemangler = FlutterError.demangleStackTrace;
712789
int exceptionCount = 0; // number of un-taken exceptions
@@ -988,6 +1065,15 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
9881065
_parentZone = null;
9891066
buildOwner!.focusManager.dispose();
9901067

1068+
if (TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger
1069+
.checkMockMessageHandler(
1070+
SystemChannels.accessibility.name, _announcementHandler)) {
1071+
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger
1072+
.setMockDecodedMessageHandler(SystemChannels.accessibility, null);
1073+
_announcementHandler = null;
1074+
}
1075+
_announcements = <CapturedAccessibilityAnnouncement>[];
1076+
9911077
ServicesBinding.instance.keyEventManager.keyMessageHandler = null;
9921078
buildOwner!.focusManager = FocusManager()..registerGlobalHandlers();
9931079

packages/flutter_test/lib/src/widget_tester.dart

+7
Original file line numberDiff line numberDiff line change
@@ -946,6 +946,13 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker
946946
return binding.takeException();
947947
}
948948

949+
/// {@macro flutter.flutter_test.TakeAccessibilityAnnouncements}
950+
///
951+
/// See [TestWidgetsFlutterBinding.takeAnnouncements] for details.
952+
List<CapturedAccessibilityAnnouncement> takeAnnouncements() {
953+
return binding.takeAnnouncements();
954+
}
955+
949956
/// Acts as if the application went idle.
950957
///
951958
/// Runs all remaining microtasks, including those scheduled as a result of

packages/flutter_test/test/widget_tester_test.dart

+73
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'package:flutter/gestures.dart';
1111
import 'package:flutter/material.dart';
1212
import 'package:flutter/rendering.dart';
1313
import 'package:flutter/scheduler.dart';
14+
import 'package:flutter/services.dart';
1415
import 'package:flutter_test/flutter_test.dart';
1516
import 'package:test_api/src/expect/async_matcher.dart'; // ignore: implementation_imports
1617
// ignore: deprecated_member_use
@@ -821,6 +822,78 @@ void main() {
821822
binding.postTest();
822823
});
823824
});
825+
826+
group('Accessibility announcements testing API', () {
827+
testWidgets('Returns the list of announcements', (WidgetTester tester) async {
828+
829+
// Make sure the handler is properly set
830+
expect(TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger
831+
.checkMockMessageHandler(SystemChannels.accessibility.name, null), isFalse);
832+
833+
await SemanticsService.announce('announcement 1', TextDirection.ltr);
834+
await SemanticsService.announce('announcement 2', TextDirection.rtl,
835+
assertiveness: Assertiveness.assertive);
836+
await SemanticsService.announce('announcement 3', TextDirection.rtl);
837+
838+
final List<CapturedAccessibilityAnnouncement> list = tester.takeAnnouncements();
839+
expect(list, hasLength(3));
840+
final CapturedAccessibilityAnnouncement first = list[0];
841+
expect(first.message, 'announcement 1');
842+
expect(first.textDirection, TextDirection.ltr);
843+
844+
final CapturedAccessibilityAnnouncement second = list[1];
845+
expect(second.message, 'announcement 2');
846+
expect(second.textDirection, TextDirection.rtl);
847+
expect(second.assertiveness, Assertiveness.assertive);
848+
849+
final CapturedAccessibilityAnnouncement third = list[2];
850+
expect(third.message, 'announcement 3');
851+
expect(third.textDirection, TextDirection.rtl);
852+
expect(third.assertiveness, Assertiveness.polite);
853+
854+
final List<CapturedAccessibilityAnnouncement> emptyList = tester.takeAnnouncements();
855+
expect(emptyList, <CapturedAccessibilityAnnouncement>[]);
856+
});
857+
858+
test('New test API is not breaking existing tests', () async {
859+
final List<Map<dynamic, dynamic>> log = <Map<dynamic, dynamic>>[];
860+
861+
Future<dynamic> handleMessage(dynamic mockMessage) async {
862+
final Map<dynamic, dynamic> message = mockMessage as Map<dynamic, dynamic>;
863+
log.add(message);
864+
}
865+
866+
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger
867+
.setMockDecodedMessageHandler<dynamic>(
868+
SystemChannels.accessibility, handleMessage);
869+
870+
await SemanticsService.announce('announcement 1', TextDirection.rtl,
871+
assertiveness: Assertiveness.assertive);
872+
expect(
873+
log,
874+
equals(<Map<String, dynamic>>[
875+
<String, dynamic>{
876+
'type': 'announce',
877+
'data': <String, dynamic>{
878+
'message': 'announcement 1',
879+
'textDirection': 0,
880+
'assertiveness': 1
881+
}
882+
},
883+
]));
884+
885+
// Remove the handler
886+
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger
887+
.setMockDecodedMessageHandler<dynamic>(
888+
SystemChannels.accessibility, null);
889+
});
890+
891+
tearDown(() {
892+
// Make sure that the handler is removed in [TestWidgetsFlutterBinding.postTest]
893+
expect(TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger
894+
.checkMockMessageHandler(SystemChannels.accessibility.name, null), isTrue);
895+
});
896+
});
824897
}
825898

826899
class FakeMatcher extends AsyncMatcher {

0 commit comments

Comments
 (0)