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.

<!-- Links -->
[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
This commit is contained in:
Emmanuel 2026-01-05 21:54:06 +01:00 committed by GitHub
parent d0e58a2d79
commit 68d3d7b421
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 128 additions and 41 deletions

View File

@ -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();

View File

@ -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),

View File

@ -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();
});

View File

@ -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 {

View File

@ -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<dynamic, dynamic> 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);

View File

@ -704,24 +704,21 @@ void main() {
);
await SemanticsService.sendAnnouncement(tester.view, 'announcement 3', TextDirection.rtl);
final List<CapturedAccessibilityAnnouncement> 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<CapturedAccessibilityAnnouncement> emptyList = tester.takeAnnouncements();
expect(emptyList, <CapturedAccessibilityAnnouncement>[]);
expect(tester.takeAnnouncements(), <CapturedAccessibilityAnnouncement>[]);
});
testWidgets('New test API is not breaking existing tests', (WidgetTester tester) async {