| // 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.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.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.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 hint overrides for the node. |
| /// |
| /// Defaults to null if not set. |
| final SemanticsHintOverrides? hintOverrides; |
| |
| 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, |
| 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 (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, |
| 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 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) || |
| (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 (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, |
| 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 List<SemanticsNode> 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 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.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 List<String> 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, |
| ); |
| } |