From 68d3d7b421e65bcdf5f8fb09227f0ec5396b7030 Mon Sep 17 00:00:00 2001 From: Emmanuel Date: Mon, 5 Jan 2026 21:54:06 +0100 Subject: [PATCH] Add accessibilityAnnouncement matcher (#180058) Fix #180057 ## Pre-launch Checklist - [X] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [X] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [X] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [X] I signed the [CLA]. - [X] I listed at least one issue that this PR fixes in the description above. - [X] I updated/added relevant documentation (doc comments with `///`). - [X] I added new tests to check the change I am making, or this PR is [test-exempt]. - [X] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [X] All existing and new tests are passing. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- .../material/calendar_date_picker_test.dart | 18 ++++-- .../test/material/expansion_tile_test.dart | 20 ++++-- .../test/widgets/autocomplete_test.dart | 10 ++- packages/flutter/test/widgets/form_test.dart | 27 ++++---- packages/flutter_test/lib/src/matchers.dart | 63 +++++++++++++++++++ .../flutter_test/test/widget_tester_test.dart | 31 +++++---- 6 files changed, 128 insertions(+), 41 deletions(-) diff --git a/packages/flutter/test/material/calendar_date_picker_test.dart b/packages/flutter/test/material/calendar_date_picker_test.dart index c300e6be1ad..f29addc1042 100644 --- a/packages/flutter/test/material/calendar_date_picker_test.dart +++ b/packages/flutter/test/material/calendar_date_picker_test.dart @@ -1492,8 +1492,10 @@ void main() { // The initial date should be announced. expect( - tester.takeAnnouncements().last.message, - '${localizations.formatFullDate(initialDate)}$semanticLabelSuffix', + tester.takeAnnouncements().last, + isAccessibilityAnnouncement( + '${localizations.formatFullDate(initialDate)}$semanticLabelSuffix', + ), ); // Select a new date. @@ -1502,8 +1504,10 @@ void main() { // The selected date should be announced. expect( - tester.takeAnnouncements().last.message, - '${localizations.selectedDateLabel} ${localizations.formatFullDate(selectedDate!)}$semanticLabelSuffix', + tester.takeAnnouncements().last, + isAccessibilityAnnouncement( + '${localizations.selectedDateLabel} ${localizations.formatFullDate(selectedDate!)}$semanticLabelSuffix', + ), ); // Select the initial date. @@ -1511,8 +1515,10 @@ void main() { // The initial date should be announced as selected. expect( - tester.takeAnnouncements().first.message, - '${localizations.selectedDateLabel} ${localizations.formatFullDate(initialDate)}$semanticLabelSuffix', + tester.takeAnnouncements().first, + isAccessibilityAnnouncement( + '${localizations.selectedDateLabel} ${localizations.formatFullDate(initialDate)}$semanticLabelSuffix', + ), ); semantics.dispose(); diff --git a/packages/flutter/test/material/expansion_tile_test.dart b/packages/flutter/test/material/expansion_tile_test.dart index f255c57e564..2929c6689e2 100644 --- a/packages/flutter/test/material/expansion_tile_test.dart +++ b/packages/flutter/test/material/expansion_tile_test.dart @@ -809,7 +809,10 @@ void main() { // The announcement should be the opposite of the current state. // The ExpansionTile is expanded, so the announcement should be // "Expanded". - expect(tester.takeAnnouncements().first.message, localizations.collapsedHint); + expect( + tester.takeAnnouncements().first, + isAccessibilityAnnouncement(localizations.collapsedHint), + ); // Tap the title to collapse ExpansionTile. await tester.tap(find.text('Title')); @@ -818,7 +821,10 @@ void main() { // The announcement should be the opposite of the current state. // The ExpansionTile is collapsed, so the announcement should be // "Collapsed". - expect(tester.takeAnnouncements().first.message, localizations.expandedHint); + expect( + tester.takeAnnouncements().first, + isAccessibilityAnnouncement(localizations.expandedHint), + ); handle.dispose(); }, // [intended] https://github.com/flutter/flutter/issues/122101. @@ -849,13 +855,19 @@ void main() { await tester.tap(find.text('Title')); await tester.pump(const Duration(seconds: 1)); // Wait for the announcement to be made. - expect(tester.takeAnnouncements().first.message, localizations.collapsedHint); + expect( + tester.takeAnnouncements().first, + isAccessibilityAnnouncement(localizations.collapsedHint), + ); // Tap the title to collapse ExpansionTile. await tester.tap(find.text('Title')); await tester.pump(const Duration(seconds: 1)); // Wait for the announcement to be made. - expect(tester.takeAnnouncements().first.message, localizations.expandedHint); + expect( + tester.takeAnnouncements().first, + isAccessibilityAnnouncement(localizations.expandedHint), + ); handle.dispose(); }, variant: TargetPlatformVariant.only(TargetPlatform.iOS), diff --git a/packages/flutter/test/widgets/autocomplete_test.dart b/packages/flutter/test/widgets/autocomplete_test.dart index 3831e277013..1fe9ce1d61b 100644 --- a/packages/flutter/test/widgets/autocomplete_test.dart +++ b/packages/flutter/test/widgets/autocomplete_test.dart @@ -3415,7 +3415,10 @@ void main() { await tester.pump(); expect(find.byKey(optionsKey), findsOneWidget); expect(lastOptions.length, kOptions.length); - expect(tester.takeAnnouncements().first.message, localizations.searchResultsFound); + expect( + tester.takeAnnouncements().first, + isAccessibilityAnnouncement(localizations.searchResultsFound), + ); await tester.enterText(find.byKey(fieldKey), 'a'); await tester.pump(); @@ -3426,7 +3429,10 @@ void main() { await tester.enterText(find.byKey(fieldKey), 'zzzz'); await tester.pump(); expect(find.byKey(optionsKey), findsNothing); - expect(tester.takeAnnouncements().first.message, localizations.noResultsFound); + expect( + tester.takeAnnouncements().first, + isAccessibilityAnnouncement(localizations.noResultsFound), + ); handle.dispose(); }); diff --git a/packages/flutter/test/widgets/form_test.dart b/packages/flutter/test/widgets/form_test.dart index a3f97eb4065..cfec05ff9e8 100644 --- a/packages/flutter/test/widgets/form_test.dart +++ b/packages/flutter/test/widgets/form_test.dart @@ -219,15 +219,15 @@ void main() { expect(find.text('Second error message'), findsOneWidget); if (test.supportsAnnounce) { - final CapturedAccessibilityAnnouncement announcement = tester.takeAnnouncements().single; - expect(announcement.message, 'First error message'); - expect(announcement.textDirection, TextDirection.ltr); - expect(announcement.assertiveness, Assertiveness.assertive); + expect(tester.takeAnnouncements(), [ + isAccessibilityAnnouncement( + 'First error message', + textDirection: TextDirection.ltr, + assertiveness: Assertiveness.assertive, + ), + ]); } else { - final CapturedAccessibilityAnnouncement? announcement = tester - .takeAnnouncements() - .firstOrNull; - expect(announcement, null); + expect(tester.takeAnnouncements(), isEmpty); } }); } @@ -443,10 +443,13 @@ void main() { await tester.pump(); expect(find.text('error'), findsOneWidget); - final CapturedAccessibilityAnnouncement announcement = tester.takeAnnouncements().single; - expect(announcement.message, 'error'); - expect(announcement.textDirection, TextDirection.ltr); - expect(announcement.assertiveness, Assertiveness.assertive); + expect(tester.takeAnnouncements(), [ + isAccessibilityAnnouncement( + 'error', + textDirection: TextDirection.ltr, + assertiveness: Assertiveness.assertive, + ), + ]); }); testWidgets('Multiple TextFormFields communicate', (WidgetTester tester) async { diff --git a/packages/flutter_test/lib/src/matchers.dart b/packages/flutter_test/lib/src/matchers.dart index 7faf4266b8b..8f8446cbcc2 100644 --- a/packages/flutter_test/lib/src/matchers.dart +++ b/packages/flutter_test/lib/src/matchers.dart @@ -1042,6 +1042,35 @@ Matcher containsSemantics({ ); } +/// Asserts that a [CapturedAccessibilityAnnouncement] matches the expected message. +/// +/// The [message] argument matches the [CapturedAccessibilityAnnouncement.message]. +/// The [textDirection] argument, if non-null, matches the [CapturedAccessibilityAnnouncement.textDirection]. +/// The [assertiveness] argument, if non-null, matches the [CapturedAccessibilityAnnouncement.assertiveness]. +/// +/// ## Sample code +/// +/// ```dart +/// await SemanticsService.sendAnnouncement(tester.view, 'Hello', TextDirection.ltr); +/// expect(tester.takeAnnouncements(), contains(isAccessibilityAnnouncement('Hello'))); +/// ``` +/// +/// See also: +/// +/// * [WidgetTester.takeAnnouncements], which retrieves the announcements in unit tests. +/// * [SemanticsService.sendAnnouncement], which sends an announcement. +Matcher isAccessibilityAnnouncement( + String message, { + TextDirection? textDirection, + Assertiveness? assertiveness, +}) { + return _MatchesAccessibilityAnnouncement( + expectedMessage: message, + expectedTextDirection: textDirection, + expectedAssertiveness: assertiveness, + ); +} + /// Asserts that the currently rendered widget meets the provided accessibility /// `guideline`. /// @@ -2966,6 +2995,40 @@ class _MatchesSemanticsData extends Matcher { } } +class _MatchesAccessibilityAnnouncement extends Matcher { + const _MatchesAccessibilityAnnouncement({ + required this.expectedMessage, + required this.expectedTextDirection, + required this.expectedAssertiveness, + }); + + final String expectedMessage; + final TextDirection? expectedTextDirection; + final Assertiveness? expectedAssertiveness; + + @override + bool matches( + covariant CapturedAccessibilityAnnouncement event, + Map matchState, + ) { + return event.message == expectedMessage && + (expectedTextDirection == null || event.textDirection == expectedTextDirection) && + (expectedAssertiveness == null || event.assertiveness == expectedAssertiveness); + } + + @override + Description describe(Description description) { + description.add('Semantic announcement with message "$expectedMessage"'); + if (expectedTextDirection != null) { + description.add(', textDirection: $expectedTextDirection'); + } + if (expectedAssertiveness != null) { + description.add(', assertiveness: $expectedAssertiveness'); + } + return description; + } +} + class _MatchesAccessibilityGuideline extends AsyncMatcher { _MatchesAccessibilityGuideline(this.guideline); diff --git a/packages/flutter_test/test/widget_tester_test.dart b/packages/flutter_test/test/widget_tester_test.dart index 440b5286b9d..9cece8441f1 100644 --- a/packages/flutter_test/test/widget_tester_test.dart +++ b/packages/flutter_test/test/widget_tester_test.dart @@ -704,24 +704,21 @@ void main() { ); await SemanticsService.sendAnnouncement(tester.view, 'announcement 3', TextDirection.rtl); - final List list = tester.takeAnnouncements(); - expect(list, hasLength(3)); - final CapturedAccessibilityAnnouncement first = list[0]; - expect(first.message, 'announcement 1'); - expect(first.textDirection, TextDirection.ltr); + expect(tester.takeAnnouncements(), [ + isAccessibilityAnnouncement('announcement 1', textDirection: TextDirection.ltr), + isAccessibilityAnnouncement( + 'announcement 2', + textDirection: TextDirection.rtl, + assertiveness: Assertiveness.assertive, + ), + isAccessibilityAnnouncement( + 'announcement 3', + textDirection: TextDirection.rtl, + assertiveness: Assertiveness.polite, + ), + ]); - final CapturedAccessibilityAnnouncement second = list[1]; - expect(second.message, 'announcement 2'); - expect(second.textDirection, TextDirection.rtl); - expect(second.assertiveness, Assertiveness.assertive); - - final CapturedAccessibilityAnnouncement third = list[2]; - expect(third.message, 'announcement 3'); - expect(third.textDirection, TextDirection.rtl); - expect(third.assertiveness, Assertiveness.polite); - - final List emptyList = tester.takeAnnouncements(); - expect(emptyList, []); + expect(tester.takeAnnouncements(), []); }); testWidgets('New test API is not breaking existing tests', (WidgetTester tester) async {