semantics_tester.dart 32.8 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
       tags = tags?.toSet() ?? <SemanticsTag>{};
57 58 59 60

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

  /// 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
88 89
  /// [dart:ui.FlutterView.devicePixelRatio] being 3.0 on the test
  /// pseudo-device).
90 91 92 93 94
  ///
  /// 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({
95
    this.id,
96 97 98 99 100
    this.flags = 0,
    this.actions = 0,
    this.label = '',
    this.hint = '',
    this.value = '',
101
    this.tooltip = '',
102 103
    this.increasedValue = '',
    this.decreasedValue = '',
Ian Hickson's avatar
Ian Hickson committed
104
    this.textDirection,
105
    this.rect,
106
    Matrix4? transform,
107 108
    this.elevation,
    this.thickness,
109
    this.textSelection,
110
    this.children = const <TestSemantics>[],
111 112
    this.scrollIndex,
    this.scrollChildren,
113
    Iterable<SemanticsTag>? tags,
114
  }) : assert(flags is int || flags is List<SemanticsFlag>),
115
       assert(actions is int || actions is List<SemanticsAction>),
116
       transform = _applyRootChildScale(transform),
117
       tags = tags?.toSet() ?? <SemanticsTag>{};
118 119 120 121 122

  /// 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.
123
  final int? id;
124

125
  /// The [SemanticsFlag]s set on this node.
126 127
  ///
  /// There are two ways to specify this property: as an `int` that encodes the
128
  /// flags as a bit field, or as a `List<SemanticsFlag>` that are _on_.
129
  ///
130
  /// Using `List<SemanticsFlag>` is recommended due to better readability.
131
  final dynamic flags;
132

133 134 135 136 137 138 139 140 141 142
  /// 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;
143 144 145 146

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

147 148 149
  /// A textual description for the value of this node.
  final String value;

150 151 152 153 154 155 156 157
  /// 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;

158 159 160 161
  /// A brief textual description of the result of the action that can be
  /// performed on this node.
  final String hint;

162 163 164
  /// A textual tooltip of this node.
  final String tooltip;

Ian Hickson's avatar
Ian Hickson committed
165 166 167 168 169
  /// 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.
170
  final TextDirection? textDirection;
Ian Hickson's avatar
Ian Hickson committed
171

172
  /// The bounding box for this node in its coordinate system.
173
  ///
174 175 176 177 178 179 180
  /// 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.
181
  final Rect? rect;
182

183 184 185
  /// The test screen's size in physical pixels, typically used as the [rect]
  /// for the node with id zero.
  ///
186 187
  /// See also:
  ///
188
  ///  * [TestSemantics.root], which uses this value to describe the root
189
  ///    node.
Dan Field's avatar
Dan Field committed
190
  static const Rect rootRect = Rect.fromLTWH(0.0, 0.0, 2400.0, 1800.0);
191 192 193

  /// 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
194
  static const Rect fullScreen = Rect.fromLTWH(0.0, 0.0, 800.0, 600.0);
195

196 197 198
  /// The transform from this node's coordinate system to its parent's coordinate system.
  ///
  /// By default, the transform is null, which represents the identity
199
  /// transformation (i.e., that this node has the same coordinate system as its
200
  /// parent).
201
  final Matrix4? transform;
202

203
  /// The elevation of this node relative to the parent node.
204 205 206 207 208
  ///
  /// See also:
  ///
  ///  * [SemanticsConfiguration.elevation] for a detailed discussion regarding
  ///    elevation and semantics.
209
  final double? elevation;
210 211 212 213 214 215

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

218
  /// The index of the first visible semantic node within a scrollable.
219
  final int? scrollIndex;
220 221

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

224
  final TextSelection? textSelection;
225

226
  static Matrix4 _applyRootChildScale(Matrix4? transform) {
227
    final Matrix4 result = Matrix4.diagonal3Values(3.0, 3.0, 1.0);
228
    if (transform != null) {
229
      result.multiply(transform);
230
    }
231 232 233
    return result;
  }

234 235 236
  /// The children of this node.
  final List<TestSemantics> children;

237 238
  /// The tags of this node.
  final Set<SemanticsTag> tags;
239

240
  bool _matches(
241
    SemanticsNode? node,
242 243 244 245 246 247
    Map<dynamic, dynamic> matchState, {
    bool ignoreRect = false,
    bool ignoreTransform = false,
    bool ignoreId = false,
    DebugSemanticsDumpOrder childOrder = DebugSemanticsDumpOrder.inverseHitTest,
  }) {
Ian Hickson's avatar
Ian Hickson committed
248
    bool fail(String message) {
249
      matchState[TestSemantics] = message;
250 251
      return false;
    }
Ian Hickson's avatar
Ian Hickson committed
252

253
    if (node == null) {
Ian Hickson's avatar
Ian Hickson committed
254
      return fail('could not find node with id $id.');
255 256
    }
    if (!ignoreId && id != node.id) {
Ian Hickson's avatar
Ian Hickson committed
257
      return fail('expected node id $id but found id ${node.id}.');
258
    }
259

260 261
    final SemanticsData nodeData = node.getSemanticsData();

262
    final int flagsBitmask = flags is int
263 264
      ? flags as int
      : (flags as List<SemanticsFlag>).fold<int>(0, (int bitmask, SemanticsFlag flag) => bitmask | flag.index);
265
    if (flagsBitmask != nodeData.flags) {
Ian Hickson's avatar
Ian Hickson committed
266
      return fail('expected node id $id to have flags $flags but found flags ${nodeData.flags}.');
267
    }
268 269

    final int actionsBitmask = actions is int
270 271
        ? actions as int
        : (actions as List<SemanticsAction>).fold<int>(0, (int bitmask, SemanticsAction action) => bitmask | action.index);
272
    if (actionsBitmask != nodeData.actions) {
Ian Hickson's avatar
Ian Hickson committed
273
      return fail('expected node id $id to have actions $actions but found actions ${nodeData.actions}.');
274
    }
275

276
    if (label != nodeData.label) {
Ian Hickson's avatar
Ian Hickson committed
277
      return fail('expected node id $id to have label "$label" but found label "${nodeData.label}".');
278 279
    }
    if (value != nodeData.value) {
280
      return fail('expected node id $id to have value "$value" but found value "${nodeData.value}".');
281 282
    }
    if (increasedValue != nodeData.increasedValue) {
283
      return fail('expected node id $id to have increasedValue "$increasedValue" but found value "${nodeData.increasedValue}".');
284 285
    }
    if (decreasedValue != nodeData.decreasedValue) {
286
      return fail('expected node id $id to have decreasedValue "$decreasedValue" but found value "${nodeData.decreasedValue}".');
287 288
    }
    if (hint != nodeData.hint) {
289
      return fail('expected node id $id to have hint "$hint" but found hint "${nodeData.hint}".');
290 291
    }
    if (tooltip != nodeData.tooltip) {
292
      return fail('expected node id $id to have tooltip "$tooltip" but found hint "${nodeData.tooltip}".');
293 294
    }
    if (textDirection != null && textDirection != nodeData.textDirection) {
Ian Hickson's avatar
Ian Hickson committed
295
      return fail('expected node id $id to have textDirection "$textDirection" but found "${nodeData.textDirection}".');
296 297
    }
    if ((nodeData.label != '' || nodeData.value != '' || nodeData.hint != '' || node.increasedValue != '' || node.decreasedValue != '') && nodeData.textDirection == null) {
298
      return fail('expected node id $id, which has a label, value, or hint, to have a textDirection, but it did not.');
299 300
    }
    if (!ignoreRect && rect != nodeData.rect) {
Ian Hickson's avatar
Ian Hickson committed
301
      return fail('expected node id $id to have rect $rect but found rect ${nodeData.rect}.');
302 303
    }
    if (!ignoreTransform && transform != nodeData.transform) {
Ian Hickson's avatar
Ian Hickson committed
304
      return fail('expected node id $id to have transform $transform but found transform:\n${nodeData.transform}.');
305
    }
306 307 308 309 310 311
    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}.');
    }
312
    if (textSelection?.baseOffset != nodeData.textSelection?.baseOffset || textSelection?.extentOffset != nodeData.textSelection?.extentOffset) {
313
      return fail('expected node id $id to have textSelection [${textSelection?.baseOffset}, ${textSelection?.end}] but found: [${nodeData.textSelection?.baseOffset}, ${nodeData.textSelection?.extentOffset}].');
314
    }
315 316 317 318 319 320
    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
321
    final int childrenCount = node.mergeAllDescendantsIntoThisNode ? 0 : node.childrenCount;
322
    if (children.length != childrenCount) {
Ian Hickson's avatar
Ian Hickson committed
323
      return fail('expected node id $id to have ${children.length} child${ children.length == 1 ? "" : "ren" } but found $childrenCount.');
324
    }
Ian Hickson's avatar
Ian Hickson committed
325

326
    if (children.isEmpty) {
327
      return true;
328
    }
329
    bool result = true;
330
    final Iterator<TestSemantics> it = children.iterator;
331
    for (final SemanticsNode child in node.debugListChildrenInOrder(childOrder)) {
332
      it.moveNext();
333
      final bool childMatches = it.current._matches(
334
        child,
335 336 337 338 339 340 341
        matchState,
        ignoreRect: ignoreRect,
        ignoreTransform: ignoreTransform,
        ignoreId: ignoreId,
        childOrder: childOrder,
      );
      if (!childMatches) {
342 343 344
        result = false;
        return false;
      }
345 346 347
    }
    if (it.moveNext()) {
      return false;
348
    }
349 350
    return result;
  }
Ian Hickson's avatar
Ian Hickson committed
351 352

  @override
353
  String toString([ int indentAmount = 0 ]) {
354
    final String indent = '  ' * indentAmount;
355
    final StringBuffer buf = StringBuffer();
Ian Hickson's avatar
Ian Hickson committed
356
    buf.writeln('$indent${objectRuntimeType(this, 'TestSemantics')}(');
357
    if (id != null) {
358
      buf.writeln('$indent  id: $id,');
359 360
    }
    if (flags is int && flags != 0 || flags is List<SemanticsFlag> && (flags as List<SemanticsFlag>).isNotEmpty) {
361
      buf.writeln('$indent  flags: ${SemanticsTester._flagsToSemanticsFlagExpression(flags)},');
362 363
    }
    if (actions is int && actions != 0 || actions is List<SemanticsAction> && (actions as List<SemanticsAction>).isNotEmpty) {
364
      buf.writeln('$indent  actions: ${SemanticsTester._actionsToSemanticsActionExpression(actions)},');
365
    }
366
    if (label != '') {
367
      buf.writeln("$indent  label: '$label',");
368
    }
369
    if (value != '') {
370
      buf.writeln("$indent  value: '$value',");
371
    }
372
    if (increasedValue != '') {
373
      buf.writeln("$indent  increasedValue: '$increasedValue',");
374
    }
375
    if (decreasedValue != '') {
376
      buf.writeln("$indent  decreasedValue: '$decreasedValue',");
377
    }
378
    if (hint != '') {
379
      buf.writeln("$indent  hint: '$hint',");
380
    }
381
    if (tooltip != '') {
382
      buf.writeln("$indent  tooltip: '$tooltip',");
383 384
    }
    if (textDirection != null) {
385
      buf.writeln('$indent  textDirection: $textDirection,');
386 387
    }
    if (textSelection?.isValid ?? false) {
388
      buf.writeln('$indent  textSelection:\n[${textSelection!.start}, ${textSelection!.end}],');
389 390
    }
    if (scrollIndex != null) {
391
      buf.writeln('$indent scrollIndex: $scrollIndex,');
392 393
    }
    if (rect != null) {
394
      buf.writeln('$indent  rect: $rect,');
395 396
    }
    if (transform != null) {
397
      buf.writeln('$indent  transform:\n${transform.toString().trim().split('\n').map<String>((String line) => '$indent    $line').join('\n')},');
398 399
    }
    if (elevation != null) {
400
      buf.writeln('$indent  elevation: $elevation,');
401 402
    }
    if (thickness != null) {
403
      buf.writeln('$indent  thickness: $thickness,');
404
    }
405
    buf.writeln('$indent  children: <TestSemantics>[');
406
    for (final TestSemantics child in children) {
407
      buf.writeln('${child.toString(indentAmount + 2)},');
408
    }
409 410
    buf.writeln('$indent  ],');
    buf.write('$indent)');
411
    return buf.toString();
Ian Hickson's avatar
Ian Hickson committed
412
  }
413 414 415 416 417 418 419 420 421 422 423
}

/// 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) {
424
    _semanticsHandle = tester.ensureSemantics();
425 426 427 428 429 430 431 432

    // 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);
433 434 435 436
  }

  /// The widget tester that this object is testing the semantics of.
  final WidgetTester tester;
437
  SemanticsHandle? _semanticsHandle;
438 439 440

  /// Release resources held by this semantics tester.
  ///
441 442 443
  /// 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.
444 445
  @mustCallSuper
  void dispose() {
446
    _semanticsHandle?.dispose();
447 448 449 450
    _semanticsHandle = null;
  }

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

453
  bool _stringAttributesEqual(List<StringAttribute> first, List<StringAttribute> second) {
454
    if (first.length != second.length) {
455
      return false;
456
    }
457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472
    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;
  }

473 474 475 476 477 478 479
  /// 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.
480
  Iterable<SemanticsNode> nodesWith({
481 482 483
    AttributedString? attributedLabel,
    AttributedString? attributedValue,
    AttributedString? attributedHint,
484 485 486 487 488 489
    String? label,
    String? value,
    String? hint,
    TextDirection? textDirection,
    List<SemanticsAction>? actions,
    List<SemanticsFlag>? flags,
490
    Set<SemanticsTag>? tags,
491 492 493 494 495 496
    double? scrollPosition,
    double? scrollExtentMax,
    double? scrollExtentMin,
    int? currentValueLength,
    int? maxValueLength,
    SemanticsNode? ancestor,
497 498
  }) {
    bool checkNode(SemanticsNode node) {
499
      if (label != null && node.label != label) {
500
        return false;
501
      }
502 503 504 505 506
      if (attributedLabel != null &&
          (attributedLabel.string != node.attributedLabel.string ||
          !_stringAttributesEqual(attributedLabel.attributes, node.attributedLabel.attributes))) {
        return false;
      }
507
      if (value != null && node.value != value) {
508
        return false;
509
      }
510 511 512 513 514
      if (attributedValue != null &&
          (attributedValue.string != node.attributedValue.string ||
          !_stringAttributesEqual(attributedValue.attributes, node.attributedValue.attributes))) {
        return false;
      }
515
      if (hint != null && node.hint != hint) {
516
        return false;
517
      }
518 519 520 521 522
      if (attributedHint != null &&
          (attributedHint.string != node.attributedHint.string ||
          !_stringAttributesEqual(attributedHint.attributes, node.attributedHint.attributes))) {
        return false;
      }
523
      if (textDirection != null && node.textDirection != textDirection) {
524
        return false;
525
      }
526
      if (actions != null) {
527
        final int expectedActions = actions.fold<int>(0, (int value, SemanticsAction action) => value | action.index);
528
        final int actualActions = node.getSemanticsData().actions;
529
        if (expectedActions != actualActions) {
530
          return false;
531
        }
532 533
      }
      if (flags != null) {
534
        final int expectedFlags = flags.fold<int>(0, (int value, SemanticsFlag flag) => value | flag.index);
535
        final int actualFlags = node.getSemanticsData().flags;
536
        if (expectedFlags != actualFlags) {
537
          return false;
538
        }
539
      }
540 541 542 543 544 545
      if (tags != null) {
        final Set<SemanticsTag>? actualTags = node.getSemanticsData().tags;
        if (!setEquals<SemanticsTag>(actualTags, tags)) {
          return false;
        }
      }
546
      if (scrollPosition != null && !nearEqual(node.scrollPosition, scrollPosition, 0.1)) {
547
        return false;
548 549
      }
      if (scrollExtentMax != null && !nearEqual(node.scrollExtentMax, scrollExtentMax, 0.1)) {
550
        return false;
551 552
      }
      if (scrollExtentMin != null && !nearEqual(node.scrollExtentMin, scrollExtentMin, 0.1)) {
553
        return false;
554
      }
555 556 557 558 559 560
      if (currentValueLength != null && node.currentValueLength != currentValueLength) {
        return false;
      }
      if (maxValueLength != null && node.maxValueLength != maxValueLength) {
        return false;
      }
561 562 563 564 565 566 567 568 569 570 571
      return true;
    }

    final List<SemanticsNode> result = <SemanticsNode>[];
    bool visit(SemanticsNode node) {
      if (checkNode(node)) {
        result.add(node);
      }
      node.visitChildren(visit);
      return true;
    }
572 573 574
    if (ancestor != null) {
      visit(ancestor);
    } else {
575
      visit(tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!);
576
    }
577 578
    return result;
  }
579 580 581 582 583 584

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

633 634
  static String _flagsToSemanticsFlagExpression(dynamic flags) {
    Iterable<SemanticsFlag> list;
635
    if (flags is int) {
636
      list = SemanticsFlag.values
637
          .where((SemanticsFlag flag) => (flag.index & flags) != 0);
638
    } else {
639
      list = flags as List<SemanticsFlag>;
640
    }
641
    return '<SemanticsFlag>[${list.join(', ')}]';
642 643
  }

644
  static String _tagsToSemanticsTagExpression(Set<SemanticsTag> tags) {
645
    return '<SemanticsTag>[${tags.map<String>((SemanticsTag tag) => "const SemanticsTag('${tag.name}')").join(', ')}]';
646 647
  }

648 649 650
  static String _actionsToSemanticsActionExpression(dynamic actions) {
    Iterable<SemanticsAction> list;
    if (actions is int) {
651
      list = SemanticsAction.values
652 653
          .where((SemanticsAction action) => (action.index & actions) != 0);
    } else {
654
      list = actions as List<SemanticsAction>;
655 656
    }
    return '<SemanticsAction>[${list.join(', ')}]';
657 658 659 660
  }

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

    buf.write(')');
713
    return buf.toString().split('\n').map<String>((String l) => '$indent$l').join('\n');
714
  }
715 716 717
}

class _HasSemantics extends Matcher {
718
  const _HasSemantics(
719
    this._semantics, {
720 721 722 723
    required this.ignoreRect,
    required this.ignoreTransform,
    required this.ignoreId,
    required this.childOrder,
724
  });
725 726

  final TestSemantics _semantics;
727 728
  final bool ignoreRect;
  final bool ignoreTransform;
729
  final bool ignoreId;
730
  final DebugSemanticsDumpOrder childOrder;
731 732

  @override
733
  bool matches(covariant SemanticsTester item, Map<dynamic, dynamic> matchState) {
734
    final bool doesMatch = _semantics._matches(
735
      item.tester.binding.pipelineOwner.semanticsOwner?.rootSemanticsNode,
736 737 738 739 740 741
      matchState,
      ignoreTransform: ignoreTransform,
      ignoreRect: ignoreRect,
      ignoreId: ignoreId,
      childOrder: childOrder,
    );
742
    if (!doesMatch) {
743
      matchState['would-match'] = item.generateTestSemanticsExpressionForCurrentSemanticsTree(childOrder);
744
    }
745 746 747
    if (item.tester.binding.pipelineOwner.semanticsOwner == null) {
      matchState['additional-notes'] = '(Check that the SemanticsTester has not been disposed early.)';
    }
748
    return doesMatch;
749 750 751 752
  }

  @override
  Description describe(Description description) {
753
    return description.add('semantics node matching:\n$_semantics');
754 755
  }

756
  String _indent(String? text) {
757
    return text.toString().trimRight().split('\n').map<String>((String line) => '  $line').join('\n');
758 759
  }

760 761
  @override
  Description describeMismatch(dynamic item, Description mismatchDescription, Map<dynamic, dynamic> matchState, bool verbose) {
762 763 764
    Description result = mismatchDescription
      .add('${matchState[TestSemantics]}\n')
      .add('Current SemanticsNode tree:\n')
765
      .add(_indent(RendererBinding.instance.renderView.debugSemantics?.toStringDeep(childOrder: childOrder)))
766 767
      .add('\n')
      .add('The semantics tree would have matched the following configuration:\n')
768
      .add(_indent(matchState['would-match'] as String));
769 770 771
    if (matchState.containsKey('additional-notes')) {
      result = result
        .add('\n')
772
        .add(matchState['additional-notes'] as String);
773 774
    }
    return result;
775 776 777 778
  }
}

/// Asserts that a [SemanticsTester] has a semantics tree that exactly matches the given semantics.
779 780
Matcher hasSemantics(
  TestSemantics semantics, {
781 782 783 784
  bool ignoreRect = false,
  bool ignoreTransform = false,
  bool ignoreId = false,
  DebugSemanticsDumpOrder childOrder = DebugSemanticsDumpOrder.traversalOrder,
785
}) {
786
  return _HasSemantics(
787 788 789 790 791 792 793
    semantics,
    ignoreRect: ignoreRect,
    ignoreTransform: ignoreTransform,
    ignoreId: ignoreId,
    childOrder: childOrder,
  );
}
794

795 796
class _IncludesNodeWith extends Matcher {
  const _IncludesNodeWith({
797 798 799
    this.attributedLabel,
    this.attributedValue,
    this.attributedHint,
800
    this.label,
801
    this.value,
802
    this.hint,
Ian Hickson's avatar
Ian Hickson committed
803
    this.textDirection,
804
    this.actions,
805
    this.flags,
806
    this.tags,
807 808 809
    this.scrollPosition,
    this.scrollExtentMax,
    this.scrollExtentMin,
810 811
    this.maxValueLength,
    this.currentValueLength,
812
  }) : assert(
813 814 815 816
       label != null ||
       value != null ||
       actions != null ||
       flags != null ||
817
       tags != null ||
818 819 820 821
       scrollPosition != null ||
       scrollExtentMax != null ||
       scrollExtentMin != null ||
       maxValueLength != null ||
822
       currentValueLength != null,
823
     );
824 825 826
  final AttributedString? attributedLabel;
  final AttributedString? attributedValue;
  final AttributedString? attributedHint;
827 828 829 830 831 832
  final String? label;
  final String? value;
  final String? hint;
  final TextDirection? textDirection;
  final List<SemanticsAction>? actions;
  final List<SemanticsFlag>? flags;
833
  final Set<SemanticsTag>? tags;
834 835 836 837 838
  final double? scrollPosition;
  final double? scrollExtentMax;
  final double? scrollExtentMin;
  final int? currentValueLength;
  final int? maxValueLength;
839 840 841

  @override
  bool matches(covariant SemanticsTester item, Map<dynamic, dynamic> matchState) {
842
    return item.nodesWith(
843 844 845
      attributedLabel: attributedLabel,
      attributedValue: attributedValue,
      attributedHint: attributedHint,
846 847
      label: label,
      value: value,
848
      hint: hint,
849 850 851
      textDirection: textDirection,
      actions: actions,
      flags: flags,
852
      tags: tags,
853 854 855
      scrollPosition: scrollPosition,
      scrollExtentMax: scrollExtentMax,
      scrollExtentMin: scrollExtentMin,
856 857
      currentValueLength: currentValueLength,
      maxValueLength: maxValueLength,
858
    ).isNotEmpty;
859 860
  }

861 862
  @override
  Description describe(Description description) {
863
    return description.add('includes node with $_configAsString');
864 865 866 867
  }

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

  String get _configAsString {
872 873 874 875
    final List<String> strings = <String>[
      if (label != null) 'label "$label"',
      if (value != null) 'value "$value"',
      if (hint != null) 'hint "$hint"',
876
      if (textDirection != null) ' (${textDirection!.name})',
877 878
      if (actions != null) 'actions "${actions!.join(', ')}"',
      if (flags != null) 'flags "${flags!.join(', ')}"',
879
      if (tags != null) 'tags "${tags!.join(', ')}"',
880 881 882
      if (scrollPosition != null) 'scrollPosition "$scrollPosition"',
      if (scrollExtentMax != null) 'scrollExtentMax "$scrollExtentMax"',
      if (scrollExtentMin != null) 'scrollExtentMin "$scrollExtentMin"',
883 884
      if (currentValueLength != null) 'currentValueLength "$currentValueLength"',
      if (maxValueLength != null) 'maxValueLength "$maxValueLength"',
885
    ];
886
    return strings.join(', ');
887 888 889
  }
}

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