blob: 03cc46dae732ae5b46a4f26505afa04e14427eca [file] [log] [blame]
// 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' show SemanticsFlag;
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 SemanticsFlag, SemanticsAction;
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.increasedValue = '',
this.decreasedValue = '',
this.hint = '',
this.textDirection,
this.rect,
this.transform,
this.elevation,
this.thickness,
this.textSelection,
this.children = const <TestSemantics>[],
this.scrollIndex,
this.scrollChildren,
Iterable<SemanticsTag>? tags,
}) : assert(flags is int || flags is List<SemanticsFlag>),
assert(actions is int || actions is List<SemanticsAction>),
assert(label != null),
assert(value != null),
assert(increasedValue != null),
assert(decreasedValue != null),
assert(hint != null),
assert(children != null),
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.textDirection,
this.transform,
this.textSelection,
this.children = const <TestSemantics>[],
this.scrollIndex,
this.scrollChildren,
Iterable<SemanticsTag>? tags,
}) : id = 0,
assert(flags is int || flags is List<SemanticsFlag>),
assert(actions is int || actions is List<SemanticsAction>),
assert(label != null),
assert(increasedValue != null),
assert(decreasedValue != null),
assert(value != null),
assert(hint != null),
rect = TestSemantics.rootRect,
elevation = 0.0,
thickness = 0.0,
assert(children != null),
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.increasedValue = '',
this.decreasedValue = '',
this.textDirection,
this.rect,
Matrix4? transform,
this.elevation,
this.thickness,
this.textSelection,
this.children = const <TestSemantics>[],
this.scrollIndex,
this.scrollChildren,
Iterable<SemanticsTag>? tags,
}) : assert(flags is int || flags is List<SemanticsFlag>),
assert(actions is int || actions is List<SemanticsAction>),
assert(label != null),
assert(value != null),
assert(increasedValue != null),
assert(decreasedValue != null),
assert(hint != null),
transform = _applyRootChildScale(transform),
assert(children != null),
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 [SemanticsFlag]s set on this node.
///
/// There are two ways to specify this property: as an `int` that encodes the
/// flags as a bit field, or as a `List<SemanticsFlag>` that are _on_.
///
/// Using `List<SemanticsFlag>` is recommended due to better readability.
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;
/// 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:
///
/// * [new 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 elevation of this node relative to the parent node.
///
/// See also:
///
/// * [SemanticsConfiguration.elevation] for a detailed discussion regarding
/// elevation and semantics.
final double? elevation;
/// The extend that this node occupies in z-direction starting at [elevation].
///
/// See also:
///
/// * [SemanticsConfiguration.thickness] for a more detailed definition.
final double? thickness;
/// 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;
final TextSelection? textSelection;
static Matrix4 _applyRootChildScale(Matrix4? transform) {
final Matrix4 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,
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();
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 (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 (elevation != null && elevation != nodeData.elevation) {
return fail('expected node id $id to have elevation $elevation but found elevation:\n${nodeData.elevation}.');
}
if (thickness != null && thickness != nodeData.thickness) {
return fail('expected node id $id to have thickness $thickness but found thickness:\n${nodeData.thickness}.');
}
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 = 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 (children.isEmpty)
return true;
bool 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,
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 StringBuffer 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)
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 != null && label != '')
buf.writeln("$indent label: '$label',");
if (value != null && value != '')
buf.writeln("$indent value: '$value',");
if (increasedValue != null && increasedValue != '')
buf.writeln("$indent increasedValue: '$increasedValue',");
if (decreasedValue != null && decreasedValue != '')
buf.writeln("$indent decreasedValue: '$decreasedValue',");
if (hint != null && hint != '')
buf.writeln("$indent hint: '$hint',");
if (textDirection != null)
buf.writeln('$indent textDirection: $textDirection,');
if (textSelection?.isValid == true)
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 (elevation != null)
buf.writeln('$indent elevation: $elevation,');
if (thickness != null)
buf.writeln('$indent thickness: $thickness,');
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.binding.pipelineOwner.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 (int 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,
TextDirection? textDirection,
List<SemanticsAction>? actions,
List<SemanticsFlag>? flags,
double? scrollPosition,
double? scrollExtentMax,
double? scrollExtentMin,
int? currentValueLength,
int? maxValueLength,
SemanticsNode? ancestor,
}) {
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 (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 (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 (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;
}
return true;
}
final List<SemanticsNode> result = <SemanticsNode>[];
bool visit(SemanticsNode node) {
if (checkNode(node)) {
result.add(node);
}
node.visitChildren(visit);
return true;
}
if (ancestor != null) {
visit(ancestor);
} else {
visit(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 int) {
list = SemanticsFlag.values.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.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 StringBuffer buf = StringBuffer();
final SemanticsData nodeData = node.getSemanticsData();
final bool 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 != null && 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 != null && node.value.isNotEmpty)
buf.writeln(" value: '${node.value}',");
if (node.increasedValue != null && node.increasedValue.isNotEmpty)
buf.writeln(" increasedValue: '${node.increasedValue}',");
if (node.decreasedValue != null && node.decreasedValue.isNotEmpty)
buf.writeln(" decreasedValue: '${node.decreasedValue}',");
if (node.hint != null && node.hint.isNotEmpty)
buf.writeln(" hint: '${node.hint}',");
if (node.textDirection != null)
buf.writeln(' textDirection: ${node.textDirection},');
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.childOrder,
}) : assert(_semantics != null),
assert(ignoreRect != null),
assert(ignoreId != null),
assert(ignoreTransform != null),
assert(childOrder != null);
final TestSemantics _semantics;
final bool ignoreRect;
final bool ignoreTransform;
final bool ignoreId;
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,
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,
DebugSemanticsDumpOrder childOrder = DebugSemanticsDumpOrder.traversalOrder,
}) {
return _HasSemantics(
semantics,
ignoreRect: ignoreRect,
ignoreTransform: ignoreTransform,
ignoreId: ignoreId,
childOrder: childOrder,
);
}
class _IncludesNodeWith extends Matcher {
const _IncludesNodeWith({
this.attributedLabel,
this.attributedValue,
this.attributedHint,
this.label,
this.value,
this.hint,
this.textDirection,
this.actions,
this.flags,
this.scrollPosition,
this.scrollExtentMax,
this.scrollExtentMin,
this.maxValueLength,
this.currentValueLength,
}) : assert(
label != null ||
value != null ||
actions != null ||
flags != null ||
scrollPosition != null ||
scrollExtentMax != null ||
scrollExtentMin != null ||
maxValueLength != null ||
currentValueLength != null,
);
final AttributedString? attributedLabel;
final AttributedString? attributedValue;
final AttributedString? attributedHint;
final String? label;
final String? value;
final String? hint;
final TextDirection? textDirection;
final List<SemanticsAction>? actions;
final List<SemanticsFlag>? flags;
final double? scrollPosition;
final double? scrollExtentMax;
final double? scrollExtentMin;
final int? currentValueLength;
final int? maxValueLength;
@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,
textDirection: textDirection,
actions: actions,
flags: flags,
scrollPosition: scrollPosition,
scrollExtentMax: scrollExtentMax,
scrollExtentMin: scrollExtentMin,
currentValueLength: currentValueLength,
maxValueLength: maxValueLength,
).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 List<String> strings = <String>[
if (label != null) 'label "$label"',
if (value != null) 'value "$value"',
if (hint != null) 'hint "$hint"',
if (textDirection != null) ' (${describeEnum(textDirection!)})',
if (actions != null) 'actions "${actions!.join(', ')}"',
if (flags != null) 'flags "${flags!.join(', ')}"',
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"',
];
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,
TextDirection? textDirection,
List<SemanticsAction>? actions,
List<SemanticsFlag>? flags,
double? scrollPosition,
double? scrollExtentMax,
double? scrollExtentMin,
int? maxValueLength,
int? currentValueLength,
}) {
return _IncludesNodeWith(
label: label,
attributedLabel: attributedLabel,
value: value,
attributedValue: attributedValue,
hint: hint,
attributedHint: attributedHint,
textDirection: textDirection,
actions: actions,
flags: flags,
scrollPosition: scrollPosition,
scrollExtentMax: scrollExtentMax,
scrollExtentMin: scrollExtentMin,
maxValueLength: maxValueLength,
currentValueLength: currentValueLength,
);
}