// 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 [], this.scrollIndex, this.scrollChildren, Iterable? 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 || flags is SemanticsFlags), assert(actions is int || actions is List), tags = tags?.toSet() ?? {}; /// 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 [], this.scrollIndex, this.scrollChildren, Iterable? 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 || flags is SemanticsFlags), assert(actions is int || actions is List), rect = TestSemantics.rootRect, tags = tags?.toSet() ?? {}; /// 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 [], this.scrollIndex, this.scrollChildren, Iterable? 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 || flags is SemanticsFlags), assert(actions is int || actions is List), transform = _applyRootChildScale(transform), tags = tags?.toSet() ?? {}; /// 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` that are _on_, or as a `SemanticsFlags`. /// /// Using `SemanticsFlags` is recommended. /// /// The `int` and `List` 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`. /// /// Using `List` 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? 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 children; /// The tags of this node. final Set tags; bool _matches( SemanticsNode? node, Map 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).fold( 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).fold( 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 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 && (flags as List).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 && (actions as List).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 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: ['); 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 first, List 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 nodesWith({ AttributedString? attributedLabel, AttributedString? attributedValue, AttributedString? attributedHint, String? label, String? value, String? hint, String? increasedValue, String? decreasedValue, TextDirection? textDirection, List? actions, List? flags, SemanticsFlags? flagsCollection, Set? 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( 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( 0, (int value, SemanticsFlag flag) => value | flag.index, ); final int actualFlags = node.getSemanticsData().flags; if (expectedFlags != actualFlags) { return false; } } if (tags != null) { final Set? actualTags = node.getSemanticsData().tags; if (!setEquals(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 = []; 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 list; if (flags is SemanticsFlags) { return '[${flags.toStrings().join(', ')}]'; } else if (flags is int) { list = SemanticsFlag.values.where((SemanticsFlag flag) => (flag.index & flags) != 0); } else { list = flags as List; } return '[${list.join(', ')}]'; } static String _tagsToSemanticsTagExpression(Set tags) { return '[${tags.map((SemanticsTag tag) => "const SemanticsTag('${tag.name}')").join(', ')}]'; } static String _actionsToSemanticsActionExpression(dynamic actions) { Iterable list; if (actions is int) { list = SemanticsAction.values.where( (SemanticsAction action) => (action.index & actions) != 0, ); } else { list = actions as List; } return '[${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: ['); 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 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 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 line) => ' $line') .join('\n'); } @override Description describeMismatch( dynamic item, Description mismatchDescription, Map 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? actions; final List? flags; final SemanticsFlags? flagsCollection; final Set? 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 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 matchState, bool verbose, ) { return mismatchDescription.add('could not find node with $_configAsString.\n$_matcherHelp'); } String get _configAsString { final strings = [ 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? actions, List? flags, SemanticsFlags? flagsCollection, Set? 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, ); }