flutter_flutter/packages/flutter/test/widgets/semantics_tester.dart
chunhtai 423a30323c
Relands "Feat: Add a11y for loading indicators (#165173)" (#178402)
This reverts commit ef29db350f0951ab976e2fdb5d092e65578329e5.

<!--
Thanks for filing a pull request!
Reviewers are typically assigned within a week of filing a request.
To learn more about code review, see our documentation on Tree Hygiene:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
-->

reland https://github.com/flutter/flutter/pull/165173

## Pre-launch Checklist

- [ ] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [ ] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [ ] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [ ] I signed the [CLA].
- [ ] I listed at least one issue that this PR fixes in the description
above.
- [ ] I updated/added relevant documentation (doc comments with `///`).
- [ ] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [ ] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [ ] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

**Note**: The Flutter team is currently trialing the use of [Gemini Code
Assist for
GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot should not be taken as
authoritative feedback from the Flutter team. If you find its comments
useful you can update your code accordingly, but if you are unsure or
disagree with the feedback, please feel free to wait for a Flutter team
member's review for guidance on which automated comments should be
addressed.

<!-- 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
2026-01-02 22:43:24 +00:00

1304 lines
43 KiB
Dart

// 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:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
export 'dart:ui' show SemanticsAction, SemanticsFlag, SemanticsFlags;
export 'package:flutter/rendering.dart' show SemanticsData;
const String _matcherHelp =
'Try dumping the semantics with debugDumpSemanticsTree(DebugSemanticsDumpOrder.inverseHitTest) from the package:flutter/rendering.dart library to see what the semantics tree looks like.';
/// Test semantics data that is compared against real semantics tree.
///
/// Useful with [hasSemantics] and [SemanticsTester] to test the contents of the
/// semantics tree.
class TestSemantics {
/// Creates an object with some test semantics data.
///
/// The [id] field is required. The root node has an id of zero. Other nodes
/// are given a unique id when they are created, in a predictable fashion, and
/// so these values can be hard-coded.
///
/// The [rect] field is required and has no default. Convenient values are
/// available:
///
/// * [TestSemantics.rootRect]: 2400x1600, the test screen's size in physical
/// pixels, useful for the node with id zero.
///
/// * [TestSemantics.fullScreen] 800x600, the test screen's size in logical
/// pixels, useful for other full-screen widgets.
TestSemantics({
this.id,
this.flags = 0,
this.actions = 0,
this.label = '',
this.value = '',
this.tooltip = '',
this.headingLevel,
this.increasedValue = '',
this.decreasedValue = '',
this.hint = '',
this.textDirection,
this.rect,
this.transform,
this.textSelection,
this.children = const <TestSemantics>[],
this.scrollIndex,
this.scrollChildren,
Iterable<SemanticsTag>? tags,
this.role = SemanticsRole.none,
this.validationResult = SemanticsValidationResult.none,
this.inputType = SemanticsInputType.none,
this.controlsNodes,
this.linkUrl,
this.maxValueLength,
this.currentValueLength,
this.identifier = '',
this.traversalParentIdentifier,
this.traversalChildIdentifier,
this.locale,
this.hintOverrides,
}) : assert(flags is int || flags is List<SemanticsFlag> || flags is SemanticsFlags),
assert(actions is int || actions is List<SemanticsAction>),
tags = tags?.toSet() ?? <SemanticsTag>{};
/// Creates an object with some test semantics data, with the [id] and [rect]
/// set to the appropriate values for the root node.
TestSemantics.root({
this.flags = 0,
this.actions = 0,
this.label = '',
this.value = '',
this.increasedValue = '',
this.decreasedValue = '',
this.hint = '',
this.tooltip = '',
this.headingLevel,
this.textDirection,
this.transform,
this.textSelection,
this.children = const <TestSemantics>[],
this.scrollIndex,
this.scrollChildren,
Iterable<SemanticsTag>? tags,
this.role = SemanticsRole.none,
this.validationResult = SemanticsValidationResult.none,
this.inputType = SemanticsInputType.none,
this.controlsNodes,
this.linkUrl,
this.maxValueLength,
this.currentValueLength,
this.identifier = '',
this.traversalParentIdentifier,
this.traversalChildIdentifier,
this.locale,
this.hintOverrides,
}) : id = 0,
assert(flags is int || flags is List<SemanticsFlag> || flags is SemanticsFlags),
assert(actions is int || actions is List<SemanticsAction>),
rect = TestSemantics.rootRect,
tags = tags?.toSet() ?? <SemanticsTag>{};
/// Creates an object with some test semantics data, with the [id] and [rect]
/// set to the appropriate values for direct children of the root node.
///
/// The [transform] is set to a 3.0 scale (to account for the
/// [dart:ui.FlutterView.devicePixelRatio] being 3.0 on the test
/// pseudo-device).
///
/// The [rect] field is required and has no default. The
/// [TestSemantics.fullScreen] property may be useful as a value; it describes
/// an 800x600 rectangle, which is the test screen's size in logical pixels.
TestSemantics.rootChild({
this.id,
this.flags = 0,
this.actions = 0,
this.label = '',
this.hint = '',
this.value = '',
this.tooltip = '',
this.headingLevel,
this.increasedValue = '',
this.decreasedValue = '',
this.textDirection,
this.rect,
Matrix4? transform,
this.textSelection,
this.children = const <TestSemantics>[],
this.scrollIndex,
this.scrollChildren,
Iterable<SemanticsTag>? tags,
this.role = SemanticsRole.none,
this.validationResult = SemanticsValidationResult.none,
this.inputType = SemanticsInputType.none,
this.controlsNodes,
this.linkUrl,
this.maxValueLength,
this.currentValueLength,
this.identifier = '',
this.traversalParentIdentifier,
this.traversalChildIdentifier,
this.locale,
this.hintOverrides,
}) : assert(flags is int || flags is List<SemanticsFlag> || flags is SemanticsFlags),
assert(actions is int || actions is List<SemanticsAction>),
transform = _applyRootChildScale(transform),
tags = tags?.toSet() ?? <SemanticsTag>{};
/// The unique identifier for this node.
///
/// The root node has an id of zero. Other nodes are given a unique id when
/// they are created.
final int? id;
/// The SemanticsFlags on this node.
///
/// There are three ways to specify this property: as an `int` that encodes the
/// flags as a bit field, or as a `List<SemanticsFlag>` that are _on_, or as a `SemanticsFlags`.
///
/// Using `SemanticsFlags` is recommended.
///
/// The `int` and `List<SemanticsFlag>` types are considered deprecated as they
/// have limited bits and only support the first 31 flags.
final dynamic flags;
/// The [SemanticsAction]s set on this node.
///
/// There are two ways to specify this property: as an `int` that encodes the
/// actions as a bit field, or as a `List<SemanticsAction>`.
///
/// Using `List<SemanticsAction>` is recommended due to better readability.
///
/// The tester does not check the function corresponding to the action, but
/// only its existence.
final dynamic actions;
/// A textual description of this node.
final String label;
/// A textual description for the value of this node.
final String value;
/// What [value] will become after [SemanticsAction.increase] has been
/// performed.
final String increasedValue;
/// What [value] will become after [SemanticsAction.decrease] has been
/// performed.
final String decreasedValue;
/// A brief textual description of the result of the action that can be
/// performed on this node.
final String hint;
/// A textual tooltip of this node.
final String tooltip;
/// The reading direction of the [label].
///
/// Even if this is not set, the [hasSemantics] matcher will verify that if a
/// label is present on the [SemanticsNode], a [SemanticsNode.textDirection]
/// is also set.
final TextDirection? textDirection;
/// The bounding box for this node in its coordinate system.
///
/// Convenient values are available:
///
/// * [TestSemantics.rootRect]: 2400x1600, the test screen's size in physical
/// pixels, useful for the node with id zero.
///
/// * [TestSemantics.fullScreen] 800x600, the test screen's size in logical
/// pixels, useful for other full-screen widgets.
final Rect? rect;
/// The test screen's size in physical pixels, typically used as the [rect]
/// for the node with id zero.
///
/// See also:
///
/// * [TestSemantics.root], which uses this value to describe the root
/// node.
static const Rect rootRect = Rect.fromLTWH(0.0, 0.0, 2400.0, 1800.0);
/// The test screen's size in logical pixels, useful for the [rect] of
/// full-screen widgets other than the root node.
static const Rect fullScreen = Rect.fromLTWH(0.0, 0.0, 800.0, 600.0);
/// The transform from this node's coordinate system to its parent's coordinate system.
///
/// By default, the transform is null, which represents the identity
/// transformation (i.e., that this node has the same coordinate system as its
/// parent).
final Matrix4? transform;
/// The index of the first visible semantic node within a scrollable.
final int? scrollIndex;
/// The total number of semantic nodes within a scrollable.
final int? scrollChildren;
/// The expected text selection.
final TextSelection? textSelection;
/// The validation result for this node, if any.
///
/// See also:
///
/// * [SemanticsValidationResult], which is the enum listing possible values
/// for this field.
final SemanticsValidationResult validationResult;
/// The expected heading level
final int? headingLevel;
/// The expected role for the node.
///
/// Defaults to SemanticsRole.none if not set.
final SemanticsRole role;
/// The expected input type for the node.
///
/// Defaults to SemanticsInputType.none if not set.
final SemanticsInputType inputType;
/// The expected nodes that this node controls.
///
/// Defaults to an empty set if not set.
final Set<String>? controlsNodes;
/// The expected url for the node.
///
/// Defaults to null if not set.
final Uri? linkUrl;
/// The expected max value length for the node.
///
/// Defaults to null if not set.
final int? maxValueLength;
/// The expected current value length for the node.
///
/// Defaults to null if not set.
final int? currentValueLength;
/// The expected identifier for the node.
///
/// Defaults to an empty string if not set.
final String identifier;
/// The expected traversalParentIdentifier for the node.
///
/// Defaults to null if not set.
final Object? traversalParentIdentifier;
/// The expected traversalChildIdentifier for the node.
///
/// Defaults to null if not set.
final Object? traversalChildIdentifier;
/// The expected locale for the node.
///
/// Defaults to null if not set.
final Locale? locale;
/// The expected hint overrides for the node.
///
/// Defaults to null if not set.
final SemanticsHintOverrides? hintOverrides;
static Matrix4 _applyRootChildScale(Matrix4? transform) {
final result = Matrix4.diagonal3Values(3.0, 3.0, 1.0);
if (transform != null) {
result.multiply(transform);
}
return result;
}
/// The children of this node.
final List<TestSemantics> children;
/// The tags of this node.
final Set<SemanticsTag> tags;
bool _matches(
SemanticsNode? node,
Map<dynamic, dynamic> matchState, {
bool ignoreRect = false,
bool ignoreTransform = false,
bool ignoreId = false,
bool ignoreTraversalIdentifier = false,
DebugSemanticsDumpOrder childOrder = DebugSemanticsDumpOrder.inverseHitTest,
}) {
bool fail(String message) {
matchState[TestSemantics] = message;
return false;
}
if (node == null) {
return fail('could not find node with id $id.');
}
if (!ignoreId && id != node.id) {
return fail('expected node id $id but found id ${node.id}.');
}
final SemanticsData nodeData = node.getSemanticsData();
if (flags is SemanticsFlags) {
if (flags != nodeData.flagsCollection) {
return fail(
'expected node id $id to have flags $flags but found flags ${nodeData.flagsCollection}.',
);
}
}
// the bitmask flags only support first 31 flags.
else {
final int flagsBitmask = flags is int
? flags as int
: (flags as List<SemanticsFlag>).fold<int>(
0,
(int bitmask, SemanticsFlag flag) => bitmask | flag.index,
);
if (flagsBitmask != nodeData.flags) {
return fail('expected node id $id to have flags $flags but found flags ${nodeData.flags}.');
}
}
final int actionsBitmask = actions is int
? actions as int
: (actions as List<SemanticsAction>).fold<int>(
0,
(int bitmask, SemanticsAction action) => bitmask | action.index,
);
if (actionsBitmask != nodeData.actions) {
return fail(
'expected node id $id to have actions $actions but found actions ${nodeData.actions}.',
);
}
if (label != nodeData.label) {
return fail(
'expected node id $id to have label "$label" but found label "${nodeData.label}".',
);
}
if (value != nodeData.value) {
return fail(
'expected node id $id to have value "$value" but found value "${nodeData.value}".',
);
}
if (increasedValue != nodeData.increasedValue) {
return fail(
'expected node id $id to have increasedValue "$increasedValue" but found value "${nodeData.increasedValue}".',
);
}
if (decreasedValue != nodeData.decreasedValue) {
return fail(
'expected node id $id to have decreasedValue "$decreasedValue" but found value "${nodeData.decreasedValue}".',
);
}
if (hint != nodeData.hint) {
return fail('expected node id $id to have hint "$hint" but found hint "${nodeData.hint}".');
}
if (tooltip != nodeData.tooltip) {
return fail(
'expected node id $id to have tooltip "$tooltip" but found hint "${nodeData.tooltip}".',
);
}
if (textDirection != null && textDirection != nodeData.textDirection) {
return fail(
'expected node id $id to have textDirection "$textDirection" but found "${nodeData.textDirection}".',
);
}
if ((nodeData.label != '' ||
nodeData.value != '' ||
nodeData.hint != '' ||
node.increasedValue != '' ||
node.decreasedValue != '') &&
nodeData.textDirection == null) {
return fail(
'expected node id $id, which has a label, value, or hint, to have a textDirection, but it did not.',
);
}
if (!ignoreRect && rect != nodeData.rect) {
return fail('expected node id $id to have rect $rect but found rect ${nodeData.rect}.');
}
if (!ignoreTransform && transform != nodeData.transform) {
return fail(
'expected node id $id to have transform $transform but found transform:\n${nodeData.transform}.',
);
}
if (textSelection?.baseOffset != nodeData.textSelection?.baseOffset ||
textSelection?.extentOffset != nodeData.textSelection?.extentOffset) {
return fail(
'expected node id $id to have textSelection [${textSelection?.baseOffset}, ${textSelection?.end}] but found: [${nodeData.textSelection?.baseOffset}, ${nodeData.textSelection?.extentOffset}].',
);
}
if (scrollIndex != null && scrollIndex != nodeData.scrollIndex) {
return fail(
'expected node id $id to have scrollIndex $scrollIndex but found scrollIndex ${nodeData.scrollIndex}.',
);
}
if (scrollChildren != null && scrollChildren != nodeData.scrollChildCount) {
return fail(
'expected node id $id to have scrollIndex $scrollChildren but found scrollIndex ${nodeData.scrollChildCount}.',
);
}
final int childrenCount;
if (childOrder == DebugSemanticsDumpOrder.traversalOrder) {
childrenCount = node.mergeAllDescendantsIntoThisNode ? 0 : node.childrenCountInTraversalOrder;
} else {
childrenCount = node.mergeAllDescendantsIntoThisNode ? 0 : node.childrenCount;
}
if (children.length != childrenCount) {
return fail(
'expected node id $id to have ${children.length} child${children.length == 1 ? "" : "ren"} but found $childrenCount.',
);
}
if (headingLevel != null && headingLevel != node.headingLevel) {
return fail(
'expected node id $id to have headingLevel $headingLevel but found headingLevel ${node.headingLevel}',
);
}
if (role != node.role) {
return fail('expected node id $id to have role $role but found role ${node.role}');
}
if (validationResult != node.validationResult) {
return fail(
'expected node id $id to have validationResult $validationResult but found validationResult ${node.validationResult}',
);
}
if (inputType != node.inputType) {
return fail(
'expected node id $id to have input type $inputType but found input type ${node.inputType}',
);
}
if (controlsNodes != controlsNodes && !setEquals(controlsNodes, node.controlsNodes)) {
return fail(
'expected node id $id to controls nodes $controlsNodes but found controlling nodes ${node.controlsNodes}',
);
}
if (linkUrl?.toString() != node.linkUrl?.toString()) {
return fail(
'expected node id $id to have link url $linkUrl but found link url ${node.linkUrl}',
);
}
if (maxValueLength != node.maxValueLength) {
return fail(
'expected node id $id to have max value length $maxValueLength but found max value length ${node.maxValueLength}',
);
}
if (currentValueLength != node.currentValueLength) {
return fail(
'expected node id $id to have current value length $currentValueLength but found current value length ${node.currentValueLength}',
);
}
if (!ignoreTraversalIdentifier) {
if (traversalChildIdentifier != node.traversalChildIdentifier) {
return fail(
'expected node id $id to have traversalChildIdentifier $traversalChildIdentifier but found identifier ${node.traversalChildIdentifier}',
);
}
if (traversalParentIdentifier != node.traversalParentIdentifier) {
return fail(
'expected node id $id to have traversalParentIdentifier $traversalParentIdentifier but found identifier ${node.traversalParentIdentifier}',
);
}
}
if (hintOverrides != node.hintOverrides) {
return fail(
'expected node id $id to have hint overrides $hintOverrides but found hint overrides ${node.hintOverrides}',
);
}
if (locale != null && locale != node.getSemanticsData().locale) {
return fail(
'expected node id $id to have locale $locale but found locale ${node.getSemanticsData().locale}',
);
}
if (children.isEmpty) {
return true;
}
var result = true;
final Iterator<TestSemantics> it = children.iterator;
for (final SemanticsNode child in node.debugListChildrenInOrder(childOrder)) {
it.moveNext();
final bool childMatches = it.current._matches(
child,
matchState,
ignoreRect: ignoreRect,
ignoreTransform: ignoreTransform,
ignoreId: ignoreId,
ignoreTraversalIdentifier: ignoreTraversalIdentifier,
childOrder: childOrder,
);
if (!childMatches) {
result = false;
return false;
}
}
if (it.moveNext()) {
return false;
}
return result;
}
@override
String toString([int indentAmount = 0]) {
final String indent = ' ' * indentAmount;
final buf = StringBuffer();
buf.writeln('$indent${objectRuntimeType(this, 'TestSemantics')}(');
if (id != null) {
buf.writeln('$indent id: $id,');
}
if ((flags is int && flags != 0) ||
(flags is List<SemanticsFlag> && (flags as List<SemanticsFlag>).isNotEmpty) ||
(flags is SemanticsFlags && (flags as SemanticsFlags) != SemanticsFlags.none)) {
buf.writeln('$indent flags: ${SemanticsTester._flagsToSemanticsFlagExpression(flags)},');
}
if (actions is int && actions != 0 ||
actions is List<SemanticsAction> && (actions as List<SemanticsAction>).isNotEmpty) {
buf.writeln(
'$indent actions: ${SemanticsTester._actionsToSemanticsActionExpression(actions)},',
);
}
if (label != '') {
buf.writeln("$indent label: '$label',");
}
if (value != '') {
buf.writeln("$indent value: '$value',");
}
if (increasedValue != '') {
buf.writeln("$indent increasedValue: '$increasedValue',");
}
if (decreasedValue != '') {
buf.writeln("$indent decreasedValue: '$decreasedValue',");
}
if (hint != '') {
buf.writeln("$indent hint: '$hint',");
}
if (tooltip != '') {
buf.writeln("$indent tooltip: '$tooltip',");
}
if (textDirection != null) {
buf.writeln('$indent textDirection: $textDirection,');
}
if (textSelection?.isValid ?? false) {
buf.writeln('$indent textSelection:\n[${textSelection!.start}, ${textSelection!.end}],');
}
if (scrollIndex != null) {
buf.writeln('$indent scrollIndex: $scrollIndex,');
}
if (rect != null) {
buf.writeln('$indent rect: $rect,');
}
if (transform != null) {
buf.writeln(
'$indent transform:\n${transform.toString().trim().split('\n').map<String>((String line) => '$indent $line').join('\n')},',
);
}
if (inputType != SemanticsInputType.none) {
buf.writeln('$indent inputType: $inputType,');
}
if (controlsNodes != null) {
buf.writeln('$indent controlsNodes: $controlsNodes,');
}
if (linkUrl != null) {
buf.writeln('$indent linkUrl: $linkUrl,');
}
if (maxValueLength != null) {
buf.writeln('$indent maxValueLength: $maxValueLength,');
}
if (currentValueLength != null) {
buf.writeln('$indent currentValueLength: $currentValueLength,');
}
if (identifier.isNotEmpty) {
buf.writeln('$indent identifier: $identifier,');
}
if (hintOverrides != null) {
buf.writeln('$indent hintOverrides: $hintOverrides,');
}
buf.writeln('$indent children: <TestSemantics>[');
for (final TestSemantics child in children) {
buf.writeln('${child.toString(indentAmount + 2)},');
}
buf.writeln('$indent ],');
buf.write('$indent)');
return buf.toString();
}
}
/// Ensures that the given widget tester has a semantics tree to test.
///
/// Useful with [hasSemantics] to test the contents of the semantics tree.
class SemanticsTester {
/// Creates a semantics tester for the given widget tester.
///
/// You should call [dispose] at the end of a test that creates a semantics
/// tester.
SemanticsTester(this.tester) {
_semanticsHandle = tester.ensureSemantics();
// This _extra_ clean-up is needed for the case when a test fails and
// therefore fails to call dispose() explicitly. The test is still required
// to call dispose() explicitly, because the semanticsOwner check is
// performed irrespective of whether the owner was created via
// SemanticsTester or directly. When the test succeeds, this tear-down
// becomes a no-op.
addTearDown(dispose);
}
/// The widget tester that this object is testing the semantics of.
final WidgetTester tester;
SemanticsHandle? _semanticsHandle;
/// Release resources held by this semantics tester.
///
/// Call this function at the end of any test that uses a semantics tester. It
/// is OK to call this function multiple times. If the resources have already
/// been released, the subsequent calls have no effect.
@mustCallSuper
void dispose() {
_semanticsHandle?.dispose();
_semanticsHandle = null;
}
@override
String toString() =>
'SemanticsTester for ${tester.binding.pipelineOwner.semanticsOwner?.rootSemanticsNode}';
bool _stringAttributesEqual(List<StringAttribute> first, List<StringAttribute> second) {
if (first.length != second.length) {
return false;
}
for (var i = 0; i < first.length; i++) {
if (first[i] is SpellOutStringAttribute &&
(second[i] is! SpellOutStringAttribute || second[i].range != first[i].range)) {
return false;
}
if (first[i] is LocaleStringAttribute &&
(second[i] is! LocaleStringAttribute ||
second[i].range != first[i].range ||
(second[i] as LocaleStringAttribute).locale !=
(second[i] as LocaleStringAttribute).locale)) {
return false;
}
}
return true;
}
/// Returns all semantics nodes in the current semantics tree whose properties
/// match the non-null arguments.
///
/// If multiple arguments are non-null, each of the returned nodes must match
/// on all of them.
///
/// If `ancestor` is not null, only the descendants of it are returned.
Iterable<SemanticsNode> nodesWith({
AttributedString? attributedLabel,
AttributedString? attributedValue,
AttributedString? attributedHint,
String? label,
String? value,
String? hint,
String? increasedValue,
String? decreasedValue,
TextDirection? textDirection,
List<SemanticsAction>? actions,
List<SemanticsFlag>? flags,
SemanticsFlags? flagsCollection,
Set<SemanticsTag>? tags,
double? scrollPosition,
double? scrollExtentMax,
double? scrollExtentMin,
int? currentValueLength,
int? maxValueLength,
String? maxValue,
String? minValue,
SemanticsNode? ancestor,
SemanticsInputType? inputType,
}) {
bool checkNode(SemanticsNode node) {
if (label != null && node.label != label) {
return false;
}
if (attributedLabel != null &&
(attributedLabel.string != node.attributedLabel.string ||
!_stringAttributesEqual(
attributedLabel.attributes,
node.attributedLabel.attributes,
))) {
return false;
}
if (value != null && node.value != value) {
return false;
}
if (attributedValue != null &&
(attributedValue.string != node.attributedValue.string ||
!_stringAttributesEqual(
attributedValue.attributes,
node.attributedValue.attributes,
))) {
return false;
}
if (hint != null && node.hint != hint) {
return false;
}
if (increasedValue != null && node.increasedValue != increasedValue) {
return false;
}
if (decreasedValue != null && node.decreasedValue != decreasedValue) {
return false;
}
if (attributedHint != null &&
(attributedHint.string != node.attributedHint.string ||
!_stringAttributesEqual(attributedHint.attributes, node.attributedHint.attributes))) {
return false;
}
if (textDirection != null && node.textDirection != textDirection) {
return false;
}
if (actions != null) {
final int expectedActions = actions.fold<int>(
0,
(int value, SemanticsAction action) => value | action.index,
);
final int actualActions = node.getSemanticsData().actions;
if (expectedActions != actualActions) {
return false;
}
}
if (flagsCollection != null) {
final SemanticsFlags expectedFlags = flagsCollection;
final SemanticsFlags actualFlags = node.getSemanticsData().flagsCollection;
if (expectedFlags != actualFlags) {
return false;
}
}
// `flags` are deprecated and only support the first 31 flags.
else if (flags != null) {
final int expectedFlags = flags.fold<int>(
0,
(int value, SemanticsFlag flag) => value | flag.index,
);
final int actualFlags = node.getSemanticsData().flags;
if (expectedFlags != actualFlags) {
return false;
}
}
if (tags != null) {
final Set<SemanticsTag>? actualTags = node.getSemanticsData().tags;
if (!setEquals<SemanticsTag>(actualTags, tags)) {
return false;
}
}
if (scrollPosition != null && !nearEqual(node.scrollPosition, scrollPosition, 0.1)) {
return false;
}
if (scrollExtentMax != null && !nearEqual(node.scrollExtentMax, scrollExtentMax, 0.1)) {
return false;
}
if (scrollExtentMin != null && !nearEqual(node.scrollExtentMin, scrollExtentMin, 0.1)) {
return false;
}
if (currentValueLength != null && node.currentValueLength != currentValueLength) {
return false;
}
if (maxValueLength != null && node.maxValueLength != maxValueLength) {
return false;
}
if (inputType != null && node.inputType != inputType) {
return false;
}
if (maxValue != null && node.maxValue != maxValue) {
return false;
}
if (minValue != null && node.minValue != minValue) {
return false;
}
return true;
}
final result = <SemanticsNode>[];
bool visit(SemanticsNode node) {
if (checkNode(node)) {
result.add(node);
}
node.visitChildren(visit);
return true;
}
visit(ancestor ?? tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!);
return result;
}
/// Generates an expression that creates a [TestSemantics] reflecting the
/// current tree of [SemanticsNode]s.
///
/// Use this method to generate code for unit tests. It works similar to
/// screenshot testing. The very first time you add semantics to a widget you
/// verify manually that the widget behaves correctly. You then use this
/// method to generate test code for this widget.
///
/// Example:
///
/// ```dart
/// testWidgets('generate code for MyWidget', (WidgetTester tester) async {
/// var semantics = SemanticsTester(tester);
/// await tester.pumpWidget(MyWidget());
/// print(semantics.generateTestSemanticsExpressionForCurrentSemanticsTree());
/// semantics.dispose();
/// });
/// ```
///
/// You can now copy the code printed to the console into a unit test:
///
/// ```dart
/// testWidgets('generate code for MyWidget', (WidgetTester tester) async {
/// var semantics = SemanticsTester(tester);
/// await tester.pumpWidget(MyWidget());
/// expect(semantics, hasSemantics(
/// // Generated code:
/// TestSemantics(
/// ... properties and child nodes ...
/// ),
/// ignoreRect: true,
/// ignoreTransform: true,
/// ignoreId: true,
/// ));
/// semantics.dispose();
/// });
/// ```
///
/// At this point the unit test should automatically pass because it was
/// generated from the actual [SemanticsNode]s. Next time the semantics tree
/// changes, the test code may either be updated manually, or regenerated and
/// replaced using this method again.
///
/// Avoid submitting huge piles of generated test code. This will make test
/// code hard to review and it will make it tempting to regenerate test code
/// every time and ignore potential regressions. Make sure you do not
/// over-test. Prefer breaking your widgets into smaller widgets and test them
/// individually.
String generateTestSemanticsExpressionForCurrentSemanticsTree(
DebugSemanticsDumpOrder childOrder,
) {
final SemanticsNode? node = tester.binding.pipelineOwner.semanticsOwner?.rootSemanticsNode;
return _generateSemanticsTestForNode(node, 0, childOrder);
}
static String _flagsToSemanticsFlagExpression(dynamic flags) {
Iterable<SemanticsFlag> list;
if (flags is SemanticsFlags) {
return '<SemanticsFlag>[${flags.toStrings().join(', ')}]';
} else if (flags is int) {
list = SemanticsFlag.values.where((SemanticsFlag flag) => (flag.index & flags) != 0);
} else {
list = flags as List<SemanticsFlag>;
}
return '<SemanticsFlag>[${list.join(', ')}]';
}
static String _tagsToSemanticsTagExpression(Set<SemanticsTag> tags) {
return '<SemanticsTag>[${tags.map<String>((SemanticsTag tag) => "const SemanticsTag('${tag.name}')").join(', ')}]';
}
static String _actionsToSemanticsActionExpression(dynamic actions) {
Iterable<SemanticsAction> list;
if (actions is int) {
list = SemanticsAction.values.where(
(SemanticsAction action) => (action.index & actions) != 0,
);
} else {
list = actions as List<SemanticsAction>;
}
return '<SemanticsAction>[${list.join(', ')}]';
}
/// Recursively generates [TestSemantics] code for [node] and its children,
/// indenting the expression by `indentAmount`.
static String _generateSemanticsTestForNode(
SemanticsNode? node,
int indentAmount,
DebugSemanticsDumpOrder childOrder,
) {
if (node == null) {
return 'null';
}
final String indent = ' ' * indentAmount;
final buf = StringBuffer();
final SemanticsData nodeData = node.getSemanticsData();
final isRoot = node.id == 0;
buf.writeln('TestSemantics${isRoot ? '.root' : ''}(');
if (!isRoot) {
buf.writeln(' id: ${node.id},');
}
if (nodeData.tags != null) {
buf.writeln(' tags: ${_tagsToSemanticsTagExpression(nodeData.tags!)},');
}
if (nodeData.flags != 0) {
buf.writeln(' flags: ${_flagsToSemanticsFlagExpression(nodeData.flags)},');
}
if (nodeData.actions != 0) {
buf.writeln(' actions: ${_actionsToSemanticsActionExpression(nodeData.actions)},');
}
if (node.label.isNotEmpty) {
// Escape newlines and text directionality control characters.
final String escapedLabel = node.label
.replaceAll('\n', r'\n')
.replaceAll('\u202a', r'\u202a')
.replaceAll('\u202c', r'\u202c');
buf.writeln(" label: '$escapedLabel',");
}
if (node.value.isNotEmpty) {
buf.writeln(" value: '${node.value}',");
}
if (node.increasedValue.isNotEmpty) {
buf.writeln(" increasedValue: '${node.increasedValue}',");
}
if (node.decreasedValue.isNotEmpty) {
buf.writeln(" decreasedValue: '${node.decreasedValue}',");
}
if (node.hint.isNotEmpty) {
buf.writeln(" hint: '${node.hint}',");
}
if (node.textDirection != null) {
buf.writeln(' textDirection: ${node.textDirection},');
}
if (node.role != SemanticsRole.none) {
buf.writeln(' role: ${node.role},');
}
if (node.inputType != SemanticsInputType.none) {
buf.writeln(' inputType: ${node.inputType},');
}
if (node.controlsNodes != null) {
buf.writeln(' controlsNodes: ${node.controlsNodes},');
}
if (node.linkUrl != null) {
buf.writeln(' linkUrl: ${node.linkUrl},');
}
if (node.maxValueLength != null) {
buf.writeln(' maxValueLength: ${node.maxValueLength},');
}
if (node.currentValueLength != null) {
buf.writeln(' currentValueLength: ${node.currentValueLength},');
}
if (node.identifier.isNotEmpty) {
buf.writeln(' identifier: ${node.identifier},');
}
if (node.hintOverrides != null) {
buf.writeln(' hintOverrides: ${node.hintOverrides},');
}
if (node.hasChildren) {
buf.writeln(' children: <TestSemantics>[');
for (final SemanticsNode child in node.debugListChildrenInOrder(childOrder)) {
buf
..write(_generateSemanticsTestForNode(child, 2, childOrder))
..writeln(',');
}
buf.writeln(' ],');
}
buf.write(')');
return buf.toString().split('\n').map<String>((String l) => '$indent$l').join('\n');
}
}
class _HasSemantics extends Matcher {
const _HasSemantics(
this._semantics, {
required this.ignoreRect,
required this.ignoreTransform,
required this.ignoreId,
required this.ignoreTraversalIdentifier,
required this.childOrder,
});
final TestSemantics _semantics;
final bool ignoreRect;
final bool ignoreTransform;
final bool ignoreId;
final bool ignoreTraversalIdentifier;
final DebugSemanticsDumpOrder childOrder;
@override
bool matches(covariant SemanticsTester item, Map<dynamic, dynamic> matchState) {
final bool doesMatch = _semantics._matches(
item.tester.binding.pipelineOwner.semanticsOwner?.rootSemanticsNode,
matchState,
ignoreTransform: ignoreTransform,
ignoreRect: ignoreRect,
ignoreId: ignoreId,
ignoreTraversalIdentifier: ignoreTraversalIdentifier,
childOrder: childOrder,
);
if (!doesMatch) {
matchState['would-match'] = item.generateTestSemanticsExpressionForCurrentSemanticsTree(
childOrder,
);
}
if (item.tester.binding.pipelineOwner.semanticsOwner == null) {
matchState['additional-notes'] =
'(Check that the SemanticsTester has not been disposed early.)';
}
return doesMatch;
}
@override
Description describe(Description description) {
return description.add('semantics node matching:\n$_semantics');
}
String _indent(String? text) {
return text
.toString()
.trimRight()
.split('\n')
.map<String>((String line) => ' $line')
.join('\n');
}
@override
Description describeMismatch(
dynamic item,
Description mismatchDescription,
Map<dynamic, dynamic> matchState,
bool verbose,
) {
Description result = mismatchDescription
.add('${matchState[TestSemantics]}\n')
.add('Current SemanticsNode tree:\n')
.add(
_indent(
RendererBinding.instance.renderView.debugSemantics?.toStringDeep(
childOrder: childOrder,
),
),
)
.add('\n')
.add('The semantics tree would have matched the following configuration:\n')
.add(_indent(matchState['would-match'] as String));
if (matchState.containsKey('additional-notes')) {
result = result.add('\n').add(matchState['additional-notes'] as String);
}
return result;
}
}
/// Asserts that a [SemanticsTester] has a semantics tree that exactly matches the given semantics.
Matcher hasSemantics(
TestSemantics semantics, {
bool ignoreRect = false,
bool ignoreTransform = false,
bool ignoreId = false,
bool ignoreTraversalIdentifier = true,
DebugSemanticsDumpOrder childOrder = DebugSemanticsDumpOrder.traversalOrder,
}) {
return _HasSemantics(
semantics,
ignoreRect: ignoreRect,
ignoreTransform: ignoreTransform,
ignoreId: ignoreId,
ignoreTraversalIdentifier: ignoreTraversalIdentifier,
childOrder: childOrder,
);
}
class _IncludesNodeWith extends Matcher {
const _IncludesNodeWith({
this.attributedLabel,
this.attributedValue,
this.attributedHint,
this.label,
this.value,
this.hint,
this.increasedValue,
this.decreasedValue,
this.textDirection,
this.actions,
this.flags,
this.flagsCollection,
this.tags,
this.scrollPosition,
this.scrollExtentMax,
this.scrollExtentMin,
this.maxValueLength,
this.currentValueLength,
this.inputType,
this.minValue,
this.maxValue,
}) : assert(
label != null ||
value != null ||
actions != null ||
flags != null ||
flagsCollection != null ||
tags != null ||
increasedValue != null ||
decreasedValue != null ||
scrollPosition != null ||
scrollExtentMax != null ||
scrollExtentMin != null ||
maxValueLength != null ||
currentValueLength != null ||
inputType != null,
minValue != null || maxValue != null,
);
final AttributedString? attributedLabel;
final AttributedString? attributedValue;
final AttributedString? attributedHint;
final String? label;
final String? value;
final String? hint;
final String? increasedValue;
final String? decreasedValue;
final TextDirection? textDirection;
final List<SemanticsAction>? actions;
final List<SemanticsFlag>? flags;
final SemanticsFlags? flagsCollection;
final Set<SemanticsTag>? tags;
final double? scrollPosition;
final double? scrollExtentMax;
final double? scrollExtentMin;
final int? currentValueLength;
final int? maxValueLength;
final SemanticsInputType? inputType;
final String? minValue;
final String? maxValue;
@override
bool matches(covariant SemanticsTester item, Map<dynamic, dynamic> matchState) {
return item
.nodesWith(
attributedLabel: attributedLabel,
attributedValue: attributedValue,
attributedHint: attributedHint,
label: label,
value: value,
hint: hint,
increasedValue: increasedValue,
decreasedValue: decreasedValue,
textDirection: textDirection,
actions: actions,
flags: flags,
flagsCollection: flagsCollection,
tags: tags,
scrollPosition: scrollPosition,
scrollExtentMax: scrollExtentMax,
scrollExtentMin: scrollExtentMin,
currentValueLength: currentValueLength,
maxValueLength: maxValueLength,
inputType: inputType,
minValue: minValue,
maxValue: maxValue,
)
.isNotEmpty;
}
@override
Description describe(Description description) {
return description.add('includes node with $_configAsString');
}
@override
Description describeMismatch(
dynamic item,
Description mismatchDescription,
Map<dynamic, dynamic> matchState,
bool verbose,
) {
return mismatchDescription.add('could not find node with $_configAsString.\n$_matcherHelp');
}
String get _configAsString {
final strings = <String>[
if (label != null) 'label "$label"',
if (value != null) 'value "$value"',
if (hint != null) 'hint "$hint"',
if (textDirection != null) ' (${textDirection!.name})',
if (actions != null) 'actions "${actions!.join(', ')}"',
if (flags != null) 'flags "${flags!.join(', ')}"',
if (tags != null) 'tags "${tags!.join(', ')}"',
if (increasedValue != null) 'increasedValue "$increasedValue"',
if (decreasedValue != null) 'decreasedValue "$decreasedValue"',
if (scrollPosition != null) 'scrollPosition "$scrollPosition"',
if (scrollExtentMax != null) 'scrollExtentMax "$scrollExtentMax"',
if (scrollExtentMin != null) 'scrollExtentMin "$scrollExtentMin"',
if (currentValueLength != null) 'currentValueLength "$currentValueLength"',
if (maxValueLength != null) 'maxValueLength "$maxValueLength"',
if (inputType != null) 'inputType $inputType',
if (minValue != null) 'minValue "$minValue"',
if (maxValue != null) 'maxValue "$maxValue"',
];
return strings.join(', ');
}
}
/// Asserts that a node in the semantics tree of [SemanticsTester] has `label`,
/// `textDirection`, and `actions`.
///
/// If null is provided for an argument, it will match against any value.
Matcher includesNodeWith({
String? label,
AttributedString? attributedLabel,
String? value,
AttributedString? attributedValue,
String? hint,
AttributedString? attributedHint,
String? increasedValue,
String? decreasedValue,
TextDirection? textDirection,
List<SemanticsAction>? actions,
List<SemanticsFlag>? flags,
SemanticsFlags? flagsCollection,
Set<SemanticsTag>? tags,
double? scrollPosition,
double? scrollExtentMax,
double? scrollExtentMin,
int? maxValueLength,
int? currentValueLength,
SemanticsInputType? inputType,
String? minValue,
String? maxValue,
}) {
return _IncludesNodeWith(
label: label,
attributedLabel: attributedLabel,
value: value,
attributedValue: attributedValue,
hint: hint,
attributedHint: attributedHint,
textDirection: textDirection,
increasedValue: increasedValue,
decreasedValue: decreasedValue,
actions: actions,
flags: flags,
flagsCollection: flagsCollection,
tags: tags,
scrollPosition: scrollPosition,
scrollExtentMax: scrollExtentMax,
scrollExtentMin: scrollExtentMin,
maxValueLength: maxValueLength,
currentValueLength: currentValueLength,
inputType: inputType,
minValue: minValue,
maxValue: maxValue,
);
}