semantics_tester.dart 33.4 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
import 'package:flutter/foundation.dart';
6
import 'package:flutter/physics.dart';
7 8 9
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';

10
export 'dart:ui' show SemanticsAction, SemanticsFlag;
11 12
export 'package:flutter/rendering.dart' show SemanticsData;

Ian Hickson's avatar
Ian Hickson committed
13 14
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.';

15 16 17 18 19
/// 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 {
20
  /// Creates an object with some test semantics data.
21
  ///
22 23 24 25 26 27 28 29 30 31 32 33
  /// 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.
34
  TestSemantics({
35
    this.id,
36 37 38 39
    this.flags = 0,
    this.actions = 0,
    this.label = '',
    this.value = '',
40
    this.tooltip = '',
41 42 43
    this.increasedValue = '',
    this.decreasedValue = '',
    this.hint = '',
Ian Hickson's avatar
Ian Hickson committed
44
    this.textDirection,
45
    this.rect,
46
    this.transform,
47 48
    this.elevation,
    this.thickness,
49
    this.textSelection,
50
    this.children = const <TestSemantics>[],
51 52
    this.scrollIndex,
    this.scrollChildren,
53
    Iterable<SemanticsTag>? tags,
54
  }) : assert(flags is int || flags is List<SemanticsFlag>),
55
       assert(actions is int || actions is List<SemanticsAction>),
56
       assert(label != null),
57
       assert(value != null),
58 59
       assert(increasedValue != null),
       assert(decreasedValue != null),
60
       assert(hint != null),
61
       assert(children != null),
62
       tags = tags?.toSet() ?? <SemanticsTag>{};
63 64 65 66

  /// Creates an object with some test semantics data, with the [id] and [rect]
  /// set to the appropriate values for the root node.
  TestSemantics.root({
67 68 69 70 71 72 73
    this.flags = 0,
    this.actions = 0,
    this.label = '',
    this.value = '',
    this.increasedValue = '',
    this.decreasedValue = '',
    this.hint = '',
74
    this.tooltip = '',
Ian Hickson's avatar
Ian Hickson committed
75
    this.textDirection,
76
    this.transform,
77
    this.textSelection,
78
    this.children = const <TestSemantics>[],
79 80
    this.scrollIndex,
    this.scrollChildren,
81
    Iterable<SemanticsTag>? tags,
82
  }) : id = 0,
83
       assert(flags is int || flags is List<SemanticsFlag>),
84
       assert(actions is int || actions is List<SemanticsAction>),
85
       assert(label != null),
86 87
       assert(increasedValue != null),
       assert(decreasedValue != null),
88 89
       assert(value != null),
       assert(hint != null),
90
       rect = TestSemantics.rootRect,
91 92
       elevation = 0.0,
       thickness = 0.0,
93
       assert(children != null),
94
       tags = tags?.toSet() ?? <SemanticsTag>{};
95 96 97 98 99

  /// 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
100 101
  /// [dart:ui.FlutterView.devicePixelRatio] being 3.0 on the test
  /// pseudo-device).
102 103 104 105 106
  ///
  /// 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({
107
    this.id,
108 109 110 111 112
    this.flags = 0,
    this.actions = 0,
    this.label = '',
    this.hint = '',
    this.value = '',
113
    this.tooltip = '',
114 115
    this.increasedValue = '',
    this.decreasedValue = '',
Ian Hickson's avatar
Ian Hickson committed
116
    this.textDirection,
117
    this.rect,
118
    Matrix4? transform,
119 120
    this.elevation,
    this.thickness,
121
    this.textSelection,
122
    this.children = const <TestSemantics>[],
123 124
    this.scrollIndex,
    this.scrollChildren,
125
    Iterable<SemanticsTag>? tags,
126
  }) : assert(flags is int || flags is List<SemanticsFlag>),
127
       assert(actions is int || actions is List<SemanticsAction>),
128
       assert(label != null),
129
       assert(value != null),
130 131
       assert(increasedValue != null),
       assert(decreasedValue != null),
132
       assert(hint != null),
133
       transform = _applyRootChildScale(transform),
134
       assert(children != null),
135
       tags = tags?.toSet() ?? <SemanticsTag>{};
136 137 138 139 140

  /// 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.
141
  final int? id;
142

143
  /// The [SemanticsFlag]s set on this node.
144 145
  ///
  /// There are two ways to specify this property: as an `int` that encodes the
146
  /// flags as a bit field, or as a `List<SemanticsFlag>` that are _on_.
147
  ///
148
  /// Using `List<SemanticsFlag>` is recommended due to better readability.
149
  final dynamic flags;
150

151 152 153 154 155 156 157 158 159 160
  /// 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;
161 162 163 164

  /// A textual description of this node.
  final String label;

165 166 167
  /// A textual description for the value of this node.
  final String value;

168 169 170 171 172 173 174 175
  /// 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;

176 177 178 179
  /// A brief textual description of the result of the action that can be
  /// performed on this node.
  final String hint;

180 181 182
  /// A textual tooltip of this node.
  final String tooltip;

Ian Hickson's avatar
Ian Hickson committed
183 184 185 186 187
  /// 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.
188
  final TextDirection? textDirection;
Ian Hickson's avatar
Ian Hickson committed
189

190
  /// The bounding box for this node in its coordinate system.
191
  ///
192 193 194 195 196 197 198
  /// 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.
199
  final Rect? rect;
200

201 202 203
  /// The test screen's size in physical pixels, typically used as the [rect]
  /// for the node with id zero.
  ///
204 205
  /// See also:
  ///
206
  ///  * [TestSemantics.root], which uses this value to describe the root
207
  ///    node.
Dan Field's avatar
Dan Field committed
208
  static const Rect rootRect = Rect.fromLTWH(0.0, 0.0, 2400.0, 1800.0);
209 210 211

  /// The test screen's size in logical pixels, useful for the [rect] of
  /// full-screen widgets other than the root node.
Dan Field's avatar
Dan Field committed
212
  static const Rect fullScreen = Rect.fromLTWH(0.0, 0.0, 800.0, 600.0);
213

214 215 216
  /// The transform from this node's coordinate system to its parent's coordinate system.
  ///
  /// By default, the transform is null, which represents the identity
217
  /// transformation (i.e., that this node has the same coordinate system as its
218
  /// parent).
219
  final Matrix4? transform;
220

221
  /// The elevation of this node relative to the parent node.
222 223 224 225 226
  ///
  /// See also:
  ///
  ///  * [SemanticsConfiguration.elevation] for a detailed discussion regarding
  ///    elevation and semantics.
227
  final double? elevation;
228 229 230 231 232 233

  /// The extend that this node occupies in z-direction starting at [elevation].
  ///
  /// See also:
  ///
  ///  * [SemanticsConfiguration.thickness] for a more detailed definition.
234
  final double? thickness;
235

236
  /// The index of the first visible semantic node within a scrollable.
237
  final int? scrollIndex;
238 239

  /// The total number of semantic nodes within a scrollable.
240
  final int? scrollChildren;
241

242
  final TextSelection? textSelection;
243

244
  static Matrix4 _applyRootChildScale(Matrix4? transform) {
245
    final Matrix4 result = Matrix4.diagonal3Values(3.0, 3.0, 1.0);
246
    if (transform != null) {
247
      result.multiply(transform);
248
    }
249 250 251
    return result;
  }

252 253 254
  /// The children of this node.
  final List<TestSemantics> children;

255 256
  /// The tags of this node.
  final Set<SemanticsTag> tags;
257

258
  bool _matches(
259
    SemanticsNode? node,
260 261 262 263 264 265
    Map<dynamic, dynamic> matchState, {
    bool ignoreRect = false,
    bool ignoreTransform = false,
    bool ignoreId = false,
    DebugSemanticsDumpOrder childOrder = DebugSemanticsDumpOrder.inverseHitTest,
  }) {
Ian Hickson's avatar
Ian Hickson committed
266
    bool fail(String message) {
267
      matchState[TestSemantics] = message;
268 269
      return false;
    }
Ian Hickson's avatar
Ian Hickson committed
270

271
    if (node == null) {
Ian Hickson's avatar
Ian Hickson committed
272
      return fail('could not find node with id $id.');
273 274
    }
    if (!ignoreId && id != node.id) {
Ian Hickson's avatar
Ian Hickson committed
275
      return fail('expected node id $id but found id ${node.id}.');
276
    }
277

278 279
    final SemanticsData nodeData = node.getSemanticsData();

280
    final int flagsBitmask = flags is int
281 282
      ? flags as int
      : (flags as List<SemanticsFlag>).fold<int>(0, (int bitmask, SemanticsFlag flag) => bitmask | flag.index);
283
    if (flagsBitmask != nodeData.flags) {
Ian Hickson's avatar
Ian Hickson committed
284
      return fail('expected node id $id to have flags $flags but found flags ${nodeData.flags}.');
285
    }
286 287

    final int actionsBitmask = actions is int
288 289
        ? actions as int
        : (actions as List<SemanticsAction>).fold<int>(0, (int bitmask, SemanticsAction action) => bitmask | action.index);
290
    if (actionsBitmask != nodeData.actions) {
Ian Hickson's avatar
Ian Hickson committed
291
      return fail('expected node id $id to have actions $actions but found actions ${nodeData.actions}.');
292
    }
293

294
    if (label != nodeData.label) {
Ian Hickson's avatar
Ian Hickson committed
295
      return fail('expected node id $id to have label "$label" but found label "${nodeData.label}".');
296 297
    }
    if (value != nodeData.value) {
298
      return fail('expected node id $id to have value "$value" but found value "${nodeData.value}".');
299 300
    }
    if (increasedValue != nodeData.increasedValue) {
301
      return fail('expected node id $id to have increasedValue "$increasedValue" but found value "${nodeData.increasedValue}".');
302 303
    }
    if (decreasedValue != nodeData.decreasedValue) {
304
      return fail('expected node id $id to have decreasedValue "$decreasedValue" but found value "${nodeData.decreasedValue}".');
305 306
    }
    if (hint != nodeData.hint) {
307
      return fail('expected node id $id to have hint "$hint" but found hint "${nodeData.hint}".');
308 309
    }
    if (tooltip != nodeData.tooltip) {
310
      return fail('expected node id $id to have tooltip "$tooltip" but found hint "${nodeData.tooltip}".');
311 312
    }
    if (textDirection != null && textDirection != nodeData.textDirection) {
Ian Hickson's avatar
Ian Hickson committed
313
      return fail('expected node id $id to have textDirection "$textDirection" but found "${nodeData.textDirection}".');
314 315
    }
    if ((nodeData.label != '' || nodeData.value != '' || nodeData.hint != '' || node.increasedValue != '' || node.decreasedValue != '') && nodeData.textDirection == null) {
316
      return fail('expected node id $id, which has a label, value, or hint, to have a textDirection, but it did not.');
317 318
    }
    if (!ignoreRect && rect != nodeData.rect) {
Ian Hickson's avatar
Ian Hickson committed
319
      return fail('expected node id $id to have rect $rect but found rect ${nodeData.rect}.');
320 321
    }
    if (!ignoreTransform && transform != nodeData.transform) {
Ian Hickson's avatar
Ian Hickson committed
322
      return fail('expected node id $id to have transform $transform but found transform:\n${nodeData.transform}.');
323
    }
324 325 326 327 328 329
    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}.');
    }
330
    if (textSelection?.baseOffset != nodeData.textSelection?.baseOffset || textSelection?.extentOffset != nodeData.textSelection?.extentOffset) {
331
      return fail('expected node id $id to have textSelection [${textSelection?.baseOffset}, ${textSelection?.end}] but found: [${nodeData.textSelection?.baseOffset}, ${nodeData.textSelection?.extentOffset}].');
332
    }
333 334 335 336 337 338
    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}.');
    }
Ian Hickson's avatar
Ian Hickson committed
339
    final int childrenCount = node.mergeAllDescendantsIntoThisNode ? 0 : node.childrenCount;
340
    if (children.length != childrenCount) {
Ian Hickson's avatar
Ian Hickson committed
341
      return fail('expected node id $id to have ${children.length} child${ children.length == 1 ? "" : "ren" } but found $childrenCount.');
342
    }
Ian Hickson's avatar
Ian Hickson committed
343

344
    if (children.isEmpty) {
345
      return true;
346
    }
347
    bool result = true;
348
    final Iterator<TestSemantics> it = children.iterator;
349
    for (final SemanticsNode child in node.debugListChildrenInOrder(childOrder)) {
350
      it.moveNext();
351
      final bool childMatches = it.current._matches(
352
        child,
353 354 355 356 357 358 359
        matchState,
        ignoreRect: ignoreRect,
        ignoreTransform: ignoreTransform,
        ignoreId: ignoreId,
        childOrder: childOrder,
      );
      if (!childMatches) {
360 361 362
        result = false;
        return false;
      }
363 364 365
    }
    if (it.moveNext()) {
      return false;
366
    }
367 368
    return result;
  }
Ian Hickson's avatar
Ian Hickson committed
369 370

  @override
371
  String toString([ int indentAmount = 0 ]) {
372
    final String indent = '  ' * indentAmount;
373
    final StringBuffer buf = StringBuffer();
Ian Hickson's avatar
Ian Hickson committed
374
    buf.writeln('$indent${objectRuntimeType(this, 'TestSemantics')}(');
375
    if (id != null) {
376
      buf.writeln('$indent  id: $id,');
377 378
    }
    if (flags is int && flags != 0 || flags is List<SemanticsFlag> && (flags as List<SemanticsFlag>).isNotEmpty) {
379
      buf.writeln('$indent  flags: ${SemanticsTester._flagsToSemanticsFlagExpression(flags)},');
380 381
    }
    if (actions is int && actions != 0 || actions is List<SemanticsAction> && (actions as List<SemanticsAction>).isNotEmpty) {
382
      buf.writeln('$indent  actions: ${SemanticsTester._actionsToSemanticsActionExpression(actions)},');
383 384
    }
    if (label != null && label != '') {
385
      buf.writeln("$indent  label: '$label',");
386 387
    }
    if (value != null && value != '') {
388
      buf.writeln("$indent  value: '$value',");
389 390
    }
    if (increasedValue != null && increasedValue != '') {
391
      buf.writeln("$indent  increasedValue: '$increasedValue',");
392 393
    }
    if (decreasedValue != null && decreasedValue != '') {
394
      buf.writeln("$indent  decreasedValue: '$decreasedValue',");
395 396
    }
    if (hint != null && hint != '') {
397
      buf.writeln("$indent  hint: '$hint',");
398 399
    }
    if (tooltip != null && tooltip != '') {
400
      buf.writeln("$indent  tooltip: '$tooltip',");
401 402
    }
    if (textDirection != null) {
403
      buf.writeln('$indent  textDirection: $textDirection,');
404 405
    }
    if (textSelection?.isValid ?? false) {
406
      buf.writeln('$indent  textSelection:\n[${textSelection!.start}, ${textSelection!.end}],');
407 408
    }
    if (scrollIndex != null) {
409
      buf.writeln('$indent scrollIndex: $scrollIndex,');
410 411
    }
    if (rect != null) {
412
      buf.writeln('$indent  rect: $rect,');
413 414
    }
    if (transform != null) {
415
      buf.writeln('$indent  transform:\n${transform.toString().trim().split('\n').map<String>((String line) => '$indent    $line').join('\n')},');
416 417
    }
    if (elevation != null) {
418
      buf.writeln('$indent  elevation: $elevation,');
419 420
    }
    if (thickness != null) {
421
      buf.writeln('$indent  thickness: $thickness,');
422
    }
423
    buf.writeln('$indent  children: <TestSemantics>[');
424
    for (final TestSemantics child in children) {
425
      buf.writeln('${child.toString(indentAmount + 2)},');
426
    }
427 428
    buf.writeln('$indent  ],');
    buf.write('$indent)');
429
    return buf.toString();
Ian Hickson's avatar
Ian Hickson committed
430
  }
431 432 433 434 435 436 437 438 439 440 441 442
}

/// 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();
443 444 445 446 447 448 449 450

    // 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);
451 452 453 454
  }

  /// The widget tester that this object is testing the semantics of.
  final WidgetTester tester;
455
  SemanticsHandle? _semanticsHandle;
456 457 458

  /// Release resources held by this semantics tester.
  ///
459 460 461
  /// 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.
462 463
  @mustCallSuper
  void dispose() {
464
    _semanticsHandle?.dispose();
465 466 467 468
    _semanticsHandle = null;
  }

  @override
469
  String toString() => 'SemanticsTester for ${tester.binding.pipelineOwner.semanticsOwner?.rootSemanticsNode}';
470

471
  bool _stringAttributesEqual(List<StringAttribute> first, List<StringAttribute> second) {
472
    if (first.length != second.length) {
473
      return false;
474
    }
475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490
    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;
  }

491 492 493 494 495 496 497
  /// 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.
498
  Iterable<SemanticsNode> nodesWith({
499 500 501
    AttributedString? attributedLabel,
    AttributedString? attributedValue,
    AttributedString? attributedHint,
502 503 504 505 506 507 508 509 510 511 512 513
    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,
514 515
  }) {
    bool checkNode(SemanticsNode node) {
516
      if (label != null && node.label != label) {
517
        return false;
518
      }
519 520 521 522 523
      if (attributedLabel != null &&
          (attributedLabel.string != node.attributedLabel.string ||
          !_stringAttributesEqual(attributedLabel.attributes, node.attributedLabel.attributes))) {
        return false;
      }
524
      if (value != null && node.value != value) {
525
        return false;
526
      }
527 528 529 530 531
      if (attributedValue != null &&
          (attributedValue.string != node.attributedValue.string ||
          !_stringAttributesEqual(attributedValue.attributes, node.attributedValue.attributes))) {
        return false;
      }
532
      if (hint != null && node.hint != hint) {
533
        return false;
534
      }
535 536 537 538 539
      if (attributedHint != null &&
          (attributedHint.string != node.attributedHint.string ||
          !_stringAttributesEqual(attributedHint.attributes, node.attributedHint.attributes))) {
        return false;
      }
540
      if (textDirection != null && node.textDirection != textDirection) {
541
        return false;
542
      }
543
      if (actions != null) {
544
        final int expectedActions = actions.fold<int>(0, (int value, SemanticsAction action) => value | action.index);
545
        final int actualActions = node.getSemanticsData().actions;
546
        if (expectedActions != actualActions) {
547
          return false;
548
        }
549 550
      }
      if (flags != null) {
551
        final int expectedFlags = flags.fold<int>(0, (int value, SemanticsFlag flag) => value | flag.index);
552
        final int actualFlags = node.getSemanticsData().flags;
553
        if (expectedFlags != actualFlags) {
554
          return false;
555
        }
556
      }
557
      if (scrollPosition != null && !nearEqual(node.scrollPosition, scrollPosition, 0.1)) {
558
        return false;
559 560
      }
      if (scrollExtentMax != null && !nearEqual(node.scrollExtentMax, scrollExtentMax, 0.1)) {
561
        return false;
562 563
      }
      if (scrollExtentMin != null && !nearEqual(node.scrollExtentMin, scrollExtentMin, 0.1)) {
564
        return false;
565
      }
566 567 568 569 570 571
      if (currentValueLength != null && node.currentValueLength != currentValueLength) {
        return false;
      }
      if (maxValueLength != null && node.maxValueLength != maxValueLength) {
        return false;
      }
572 573 574 575 576 577 578 579 580 581 582
      return true;
    }

    final List<SemanticsNode> result = <SemanticsNode>[];
    bool visit(SemanticsNode node) {
      if (checkNode(node)) {
        result.add(node);
      }
      node.visitChildren(visit);
      return true;
    }
583 584 585
    if (ancestor != null) {
      visit(ancestor);
    } else {
586
      visit(tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!);
587
    }
588 589
    return result;
  }
590 591 592 593 594 595

  /// 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
596 597
  /// verify manually that the widget behaves correctly. You then use this
  /// method to generate test code for this widget.
598 599 600 601 602
  ///
  /// Example:
  ///
  /// ```dart
  /// testWidgets('generate code for MyWidget', (WidgetTester tester) async {
603 604
  ///   var semantics = SemanticsTester(tester);
  ///   await tester.pumpWidget(MyWidget());
605 606 607 608 609 610 611 612 613
  ///   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 {
614 615
  ///   var semantics = SemanticsTester(tester);
  ///   await tester.pumpWidget(MyWidget());
616 617
  ///   expect(semantics, hasSemantics(
  ///     // Generated code:
618
  ///     TestSemantics(
619 620 621 622 623 624 625 626
  ///       ... properties and child nodes ...
  ///     ),
  ///     ignoreRect: true,
  ///     ignoreTransform: true,
  ///     ignoreId: true,
  ///   ));
  ///   semantics.dispose();
  /// });
627
  /// ```
628 629 630 631 632 633 634 635 636 637 638
  ///
  /// 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.
639
  String generateTestSemanticsExpressionForCurrentSemanticsTree(DebugSemanticsDumpOrder childOrder) {
640
    final SemanticsNode? node = tester.binding.pipelineOwner.semanticsOwner?.rootSemanticsNode;
641
    return _generateSemanticsTestForNode(node, 0, childOrder);
642 643
  }

644 645
  static String _flagsToSemanticsFlagExpression(dynamic flags) {
    Iterable<SemanticsFlag> list;
646
    if (flags is int) {
647 648
      list = SemanticsFlag.values.values
          .where((SemanticsFlag flag) => (flag.index & flags) != 0);
649
    } else {
650
      list = flags as List<SemanticsFlag>;
651
    }
652
    return '<SemanticsFlag>[${list.join(', ')}]';
653 654
  }

655
  static String _tagsToSemanticsTagExpression(Set<SemanticsTag> tags) {
656
    return '<SemanticsTag>[${tags.map<String>((SemanticsTag tag) => "const SemanticsTag('${tag.name}')").join(', ')}]';
657 658
  }

659 660 661 662 663 664
  static String _actionsToSemanticsActionExpression(dynamic actions) {
    Iterable<SemanticsAction> list;
    if (actions is int) {
      list = SemanticsAction.values.values
          .where((SemanticsAction action) => (action.index & actions) != 0);
    } else {
665
      list = actions as List<SemanticsAction>;
666 667
    }
    return '<SemanticsAction>[${list.join(', ')}]';
668 669 670 671
  }

  /// Recursively generates [TestSemantics] code for [node] and its children,
  /// indenting the expression by `indentAmount`.
672
  static String _generateSemanticsTestForNode(SemanticsNode? node, int indentAmount, DebugSemanticsDumpOrder childOrder) {
673
    if (node == null) {
674
      return 'null';
675
    }
676
    final String indent = '  ' * indentAmount;
677
    final StringBuffer buf = StringBuffer();
678
    final SemanticsData nodeData = node.getSemanticsData();
679
    final bool isRoot = node.id == 0;
680
    buf.writeln('TestSemantics${isRoot ? '.root': ''}(');
681
    if (!isRoot) {
682
      buf.writeln('  id: ${node.id},');
683 684
    }
    if (nodeData.tags != null) {
685
      buf.writeln('  tags: ${_tagsToSemanticsTagExpression(nodeData.tags!)},');
686 687
    }
    if (nodeData.flags != 0) {
688
      buf.writeln('  flags: ${_flagsToSemanticsFlagExpression(nodeData.flags)},');
689 690
    }
    if (nodeData.actions != 0) {
691
      buf.writeln('  actions: ${_actionsToSemanticsActionExpression(nodeData.actions)},');
692
    }
693
    if (node.label != null && node.label.isNotEmpty) {
694 695 696
      // 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',");
697
    }
698
    if (node.value != null && node.value.isNotEmpty) {
699
      buf.writeln("  value: '${node.value}',");
700 701
    }
    if (node.increasedValue != null && node.increasedValue.isNotEmpty) {
702
      buf.writeln("  increasedValue: '${node.increasedValue}',");
703 704
    }
    if (node.decreasedValue != null && node.decreasedValue.isNotEmpty) {
705
      buf.writeln("  decreasedValue: '${node.decreasedValue}',");
706 707
    }
    if (node.hint != null && node.hint.isNotEmpty) {
708
      buf.writeln("  hint: '${node.hint}',");
709 710
    }
    if (node.textDirection != null) {
711
      buf.writeln('  textDirection: ${node.textDirection},');
712
    }
713 714
    if (node.hasChildren) {
      buf.writeln('  children: <TestSemantics>[');
715
      for (final SemanticsNode child in node.debugListChildrenInOrder(childOrder)) {
716
        buf
717
          ..write(_generateSemanticsTestForNode(child, 2, childOrder))
718
          ..writeln(',');
719
      }
720 721 722 723
      buf.writeln('  ],');
    }

    buf.write(')');
724
    return buf.toString().split('\n').map<String>((String l) => '$indent$l').join('\n');
725
  }
726 727 728
}

class _HasSemantics extends Matcher {
729
  const _HasSemantics(
730
    this._semantics, {
731 732 733 734
    required this.ignoreRect,
    required this.ignoreTransform,
    required this.ignoreId,
    required this.childOrder,
735 736 737 738 739
  }) : assert(_semantics != null),
       assert(ignoreRect != null),
       assert(ignoreId != null),
       assert(ignoreTransform != null),
       assert(childOrder != null);
740 741

  final TestSemantics _semantics;
742 743
  final bool ignoreRect;
  final bool ignoreTransform;
744
  final bool ignoreId;
745
  final DebugSemanticsDumpOrder childOrder;
746 747

  @override
748
  bool matches(covariant SemanticsTester item, Map<dynamic, dynamic> matchState) {
749
    final bool doesMatch = _semantics._matches(
750
      item.tester.binding.pipelineOwner.semanticsOwner?.rootSemanticsNode,
751 752 753 754 755 756
      matchState,
      ignoreTransform: ignoreTransform,
      ignoreRect: ignoreRect,
      ignoreId: ignoreId,
      childOrder: childOrder,
    );
757
    if (!doesMatch) {
758
      matchState['would-match'] = item.generateTestSemanticsExpressionForCurrentSemanticsTree(childOrder);
759
    }
760 761 762
    if (item.tester.binding.pipelineOwner.semanticsOwner == null) {
      matchState['additional-notes'] = '(Check that the SemanticsTester has not been disposed early.)';
    }
763
    return doesMatch;
764 765 766 767
  }

  @override
  Description describe(Description description) {
768
    return description.add('semantics node matching:\n$_semantics');
769 770
  }

771
  String _indent(String? text) {
772
    return text.toString().trimRight().split('\n').map<String>((String line) => '  $line').join('\n');
773 774
  }

775 776
  @override
  Description describeMismatch(dynamic item, Description mismatchDescription, Map<dynamic, dynamic> matchState, bool verbose) {
777 778 779
    Description result = mismatchDescription
      .add('${matchState[TestSemantics]}\n')
      .add('Current SemanticsNode tree:\n')
780
      .add(_indent(RendererBinding.instance.renderView.debugSemantics?.toStringDeep(childOrder: childOrder)))
781 782
      .add('\n')
      .add('The semantics tree would have matched the following configuration:\n')
783
      .add(_indent(matchState['would-match'] as String));
784 785 786
    if (matchState.containsKey('additional-notes')) {
      result = result
        .add('\n')
787
        .add(matchState['additional-notes'] as String);
788 789
    }
    return result;
790 791 792 793
  }
}

/// Asserts that a [SemanticsTester] has a semantics tree that exactly matches the given semantics.
794 795
Matcher hasSemantics(
  TestSemantics semantics, {
796 797 798 799
  bool ignoreRect = false,
  bool ignoreTransform = false,
  bool ignoreId = false,
  DebugSemanticsDumpOrder childOrder = DebugSemanticsDumpOrder.traversalOrder,
800
}) {
801
  return _HasSemantics(
802 803 804 805 806 807 808
    semantics,
    ignoreRect: ignoreRect,
    ignoreTransform: ignoreTransform,
    ignoreId: ignoreId,
    childOrder: childOrder,
  );
}
809

810 811
class _IncludesNodeWith extends Matcher {
  const _IncludesNodeWith({
812 813 814
    this.attributedLabel,
    this.attributedValue,
    this.attributedHint,
815
    this.label,
816
    this.value,
817
    this.hint,
Ian Hickson's avatar
Ian Hickson committed
818
    this.textDirection,
819
    this.actions,
820
    this.flags,
821 822 823
    this.scrollPosition,
    this.scrollExtentMax,
    this.scrollExtentMin,
824 825
    this.maxValueLength,
    this.currentValueLength,
826
  }) : assert(
827 828 829 830 831 832 833 834
       label != null ||
       value != null ||
       actions != null ||
       flags != null ||
       scrollPosition != null ||
       scrollExtentMax != null ||
       scrollExtentMin != null ||
       maxValueLength != null ||
835
       currentValueLength != null,
836
     );
837 838 839
  final AttributedString? attributedLabel;
  final AttributedString? attributedValue;
  final AttributedString? attributedHint;
840 841 842 843 844 845 846 847 848 849 850
  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;
851 852 853

  @override
  bool matches(covariant SemanticsTester item, Map<dynamic, dynamic> matchState) {
854
    return item.nodesWith(
855 856 857
      attributedLabel: attributedLabel,
      attributedValue: attributedValue,
      attributedHint: attributedHint,
858 859
      label: label,
      value: value,
860
      hint: hint,
861 862 863
      textDirection: textDirection,
      actions: actions,
      flags: flags,
864 865 866
      scrollPosition: scrollPosition,
      scrollExtentMax: scrollExtentMax,
      scrollExtentMin: scrollExtentMin,
867 868
      currentValueLength: currentValueLength,
      maxValueLength: maxValueLength,
869
    ).isNotEmpty;
870 871
  }

872 873
  @override
  Description describe(Description description) {
874
    return description.add('includes node with $_configAsString');
875 876 877 878
  }

  @override
  Description describeMismatch(dynamic item, Description mismatchDescription, Map<dynamic, dynamic> matchState, bool verbose) {
879 880 881 882
    return mismatchDescription.add('could not find node with $_configAsString.\n$_matcherHelp');
  }

  String get _configAsString {
883 884 885 886
    final List<String> strings = <String>[
      if (label != null) 'label "$label"',
      if (value != null) 'value "$value"',
      if (hint != null) 'hint "$hint"',
887
      if (textDirection != null) ' (${textDirection!.name})',
888 889
      if (actions != null) 'actions "${actions!.join(', ')}"',
      if (flags != null) 'flags "${flags!.join(', ')}"',
890 891 892
      if (scrollPosition != null) 'scrollPosition "$scrollPosition"',
      if (scrollExtentMax != null) 'scrollExtentMax "$scrollExtentMax"',
      if (scrollExtentMin != null) 'scrollExtentMin "$scrollExtentMin"',
893 894
      if (currentValueLength != null) 'currentValueLength "$currentValueLength"',
      if (maxValueLength != null) 'maxValueLength "$maxValueLength"',
895
    ];
896
    return strings.join(', ');
897 898 899
  }
}

Ian Hickson's avatar
Ian Hickson committed
900 901
/// Asserts that a node in the semantics tree of [SemanticsTester] has `label`,
/// `textDirection`, and `actions`.
902
///
Ian Hickson's avatar
Ian Hickson committed
903
/// If null is provided for an argument, it will match against any value.
904
Matcher includesNodeWith({
905
  String? label,
906
  AttributedString? attributedLabel,
907
  String? value,
908
  AttributedString? attributedValue,
909
  String? hint,
910
  AttributedString? attributedHint,
911 912 913 914 915 916 917 918
  TextDirection? textDirection,
  List<SemanticsAction>? actions,
  List<SemanticsFlag>? flags,
  double? scrollPosition,
  double? scrollExtentMax,
  double? scrollExtentMin,
  int? maxValueLength,
  int? currentValueLength,
919
}) {
920
  return _IncludesNodeWith(
Ian Hickson's avatar
Ian Hickson committed
921
    label: label,
922
    attributedLabel: attributedLabel,
923
    value: value,
924
    attributedValue: attributedValue,
925
    hint: hint,
926
    attributedHint: attributedHint,
Ian Hickson's avatar
Ian Hickson committed
927 928
    textDirection: textDirection,
    actions: actions,
929
    flags: flags,
930 931 932
    scrollPosition: scrollPosition,
    scrollExtentMax: scrollExtentMax,
    scrollExtentMin: scrollExtentMin,
933 934
    maxValueLength: maxValueLength,
    currentValueLength: currentValueLength,
Ian Hickson's avatar
Ian Hickson committed
935 936
  );
}