semantics_tester.dart 29.7 KB
Newer Older
1 2 3 4
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
import 'dart:ui' show SemanticsFlag;
6

7
import 'package:flutter/foundation.dart';
8
import 'package:flutter/physics.dart';
9 10 11 12
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meta/meta.dart';

13
export 'dart:ui' show SemanticsFlag, SemanticsAction;
14 15
export 'package:flutter/rendering.dart' show SemanticsData;

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

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

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

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

  /// 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;

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

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

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

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

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

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

Ian Hickson's avatar
Ian Hickson committed
179 180 181 182 183 184 185
  /// 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;

186
  /// The bounding box for this node in its coordinate system.
187
  ///
188 189 190 191 192 193 194
  /// 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.
195 196
  final Rect rect;

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

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

208 209 210
  /// The transform from this node's coordinate system to its parent's coordinate system.
  ///
  /// By default, the transform is null, which represents the identity
211
  /// transformation (i.e., that this node has the same coordinate system as its
212 213 214
  /// parent).
  final Matrix4 transform;

215
  /// The elevation of this node relative to the parent node.
216 217 218 219 220 221 222 223 224 225 226 227 228 229
  ///
  /// See also:
  ///
  ///  * [SemanticsConfiguration.elevation] for a detailed discussion regarding
  ///    elevation and semantics.
  final double elevation;

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

230 231 232 233 234 235
  /// 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;

236 237
  final TextSelection textSelection;

238
  static Matrix4 _applyRootChildScale(Matrix4 transform) {
239
    final Matrix4 result = Matrix4.diagonal3Values(3.0, 3.0, 1.0);
240 241 242 243 244
    if (transform != null)
      result.multiply(transform);
    return result;
  }

245 246 247
  /// The children of this node.
  final List<TestSemantics> children;

248 249
  /// The tags of this node.
  final Set<SemanticsTag> tags;
250

251 252
  bool _matches(
    SemanticsNode node,
253 254 255 256 257 258
    Map<dynamic, dynamic> matchState, {
    bool ignoreRect = false,
    bool ignoreTransform = false,
    bool ignoreId = false,
    DebugSemanticsDumpOrder childOrder = DebugSemanticsDumpOrder.inverseHitTest,
  }) {
Ian Hickson's avatar
Ian Hickson committed
259
    bool fail(String message) {
260
      matchState[TestSemantics] = '$message';
261 262
      return false;
    }
Ian Hickson's avatar
Ian Hickson committed
263 264 265

    if (node == null)
      return fail('could not find node with id $id.');
266
    if (!ignoreId && id != node.id)
Ian Hickson's avatar
Ian Hickson committed
267
      return fail('expected node id $id but found id ${node.id}.');
268

269 270
    final SemanticsData nodeData = node.getSemanticsData();

271 272
    final int flagsBitmask = flags is int
      ? flags
273
      : flags.fold<int>(0, (int bitmask, SemanticsFlag flag) => bitmask | flag.index);
274
    if (flagsBitmask != nodeData.flags)
Ian Hickson's avatar
Ian Hickson committed
275
      return fail('expected node id $id to have flags $flags but found flags ${nodeData.flags}.');
276 277 278 279 280

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

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

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

  @override
344
  String toString([ int indentAmount = 0 ]) {
345
    final String indent = '  ' * indentAmount;
346 347
    final StringBuffer buf = StringBuffer();
    buf.writeln('$indent$runtimeType(');
348
    if (id != null)
349
      buf.writeln('$indent  id: $id,');
350 351
    if (flags is int && flags != 0 || flags is List<SemanticsFlag> && flags.isNotEmpty)
      buf.writeln('$indent  flags: ${SemanticsTester._flagsToSemanticsFlagExpression(flags)},');
352 353 354 355 356 357 358 359 360 361 362 363
    if (actions is int && actions != 0 || actions is List<SemanticsAction> && actions.isNotEmpty)
      buf.writeln('$indent  actions: ${SemanticsTester._actionsToSemanticsActionExpression(actions)},');
    if (label != null && label != '')
      buf.writeln('$indent  label: \'$label\',');
    if (value != null && value != '')
      buf.writeln('$indent  value: \'$value\',');
    if (increasedValue != null && increasedValue != '')
      buf.writeln('$indent  increasedValue: \'$increasedValue\',');
    if (decreasedValue != null && decreasedValue != '')
      buf.writeln('$indent  decreasedValue: \'$decreasedValue\',');
    if (hint != null && hint != '')
      buf.writeln('$indent  hint: \'$hint\',');
364
    if (textDirection != null)
365
      buf.writeln('$indent  textDirection: $textDirection,');
366 367
    if (textSelection?.isValid == true)
      buf.writeln('$indent  textSelection:\n[${textSelection.start}, ${textSelection.end}],');
368 369
    if (scrollIndex != null)
      buf.writeln('$indent scrollIndex: $scrollIndex,');
370
    if (rect != null)
371
      buf.writeln('$indent  rect: $rect,');
372
    if (transform != null)
373
      buf.writeln('$indent  transform:\n${transform.toString().trim().split('\n').map<String>((String line) => '$indent    $line').join('\n')},');
374 375 376 377
    if (elevation != null)
      buf.writeln('$indent  elevation: $elevation,');
    if (thickness != null)
      buf.writeln('$indent  thickness: $thickness,');
378
    buf.writeln('$indent  children: <TestSemantics>[');
379
    for (TestSemantics child in children) {
380
      buf.writeln('${child.toString(indentAmount + 2)},');
381
    }
382 383
    buf.writeln('$indent  ],');
    buf.write('$indent)');
384
    return buf.toString();
Ian Hickson's avatar
Ian Hickson committed
385
  }
386 387 388 389 390 391 392 393 394 395 396 397
}

/// 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();
398 399 400 401 402 403 404 405

    // 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);
406 407 408 409 410 411 412 413
  }

  /// The widget tester that this object is testing the semantics of.
  final WidgetTester tester;
  SemanticsHandle _semanticsHandle;

  /// Release resources held by this semantics tester.
  ///
414 415 416
  /// 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.
417 418
  @mustCallSuper
  void dispose() {
419
    _semanticsHandle?.dispose();
420 421 422 423
    _semanticsHandle = null;
  }

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

426 427 428 429 430 431 432
  /// 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.
433 434 435
  Iterable<SemanticsNode> nodesWith({
    String label,
    String value,
436
    String hint,
437 438
    TextDirection textDirection,
    List<SemanticsAction> actions,
439
    List<SemanticsFlag> flags,
440 441 442
    double scrollPosition,
    double scrollExtentMax,
    double scrollExtentMin,
443
    SemanticsNode ancestor,
444 445 446 447 448 449
  }) {
    bool checkNode(SemanticsNode node) {
      if (label != null && node.label != label)
        return false;
      if (value != null && node.value != value)
        return false;
450 451
      if (hint != null && node.hint != hint)
        return false;
452 453 454
      if (textDirection != null && node.textDirection != textDirection)
        return false;
      if (actions != null) {
455
        final int expectedActions = actions.fold<int>(0, (int value, SemanticsAction action) => value | action.index);
456 457 458 459 460
        final int actualActions = node.getSemanticsData().actions;
        if (expectedActions != actualActions)
          return false;
      }
      if (flags != null) {
461
        final int expectedFlags = flags.fold<int>(0, (int value, SemanticsFlag flag) => value | flag.index);
462 463 464 465
        final int actualFlags = node.getSemanticsData().flags;
        if (expectedFlags != actualFlags)
          return false;
      }
466 467 468 469 470 471
      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;
472 473 474 475 476 477 478 479 480 481 482
      return true;
    }

    final List<SemanticsNode> result = <SemanticsNode>[];
    bool visit(SemanticsNode node) {
      if (checkNode(node)) {
        result.add(node);
      }
      node.visitChildren(visit);
      return true;
    }
483 484 485 486 487
    if (ancestor != null) {
      visit(ancestor);
    } else {
      visit(tester.binding.pipelineOwner.semanticsOwner.rootSemanticsNode);
    }
488 489
    return result;
  }
490 491 492 493 494 495

  /// 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
496 497
  /// verify manually that the widget behaves correctly. You then use this
  /// method to generate test code for this widget.
498 499 500 501 502
  ///
  /// Example:
  ///
  /// ```dart
  /// testWidgets('generate code for MyWidget', (WidgetTester tester) async {
503 504
  ///   var semantics = SemanticsTester(tester);
  ///   await tester.pumpWidget(MyWidget());
505 506 507 508 509 510 511 512 513
  ///   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 {
514 515
  ///   var semantics = SemanticsTester(tester);
  ///   await tester.pumpWidget(MyWidget());
516 517
  ///   expect(semantics, hasSemantics(
  ///     // Generated code:
518
  ///     TestSemantics(
519 520 521 522 523 524 525 526
  ///       ... properties and child nodes ...
  ///     ),
  ///     ignoreRect: true,
  ///     ignoreTransform: true,
  ///     ignoreId: true,
  ///   ));
  ///   semantics.dispose();
  /// });
527
  /// ```
528 529 530 531 532 533 534 535 536 537 538
  ///
  /// 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.
539
  String generateTestSemanticsExpressionForCurrentSemanticsTree(DebugSemanticsDumpOrder childOrder) {
540
    final SemanticsNode node = tester.binding.pipelineOwner.semanticsOwner?.rootSemanticsNode;
541
    return _generateSemanticsTestForNode(node, 0, childOrder);
542 543
  }

544 545
  static String _flagsToSemanticsFlagExpression(dynamic flags) {
    Iterable<SemanticsFlag> list;
546
    if (flags is int) {
547 548
      list = SemanticsFlag.values.values
          .where((SemanticsFlag flag) => (flag.index & flags) != 0);
549 550 551
    } else {
      list = flags;
    }
552
    return '<SemanticsFlag>[${list.join(', ')}]';
553 554
  }

555
  static String _tagsToSemanticsTagExpression(Set<SemanticsTag> tags) {
556
    return '<SemanticsTag>[${tags.map<String>((SemanticsTag tag) => 'const SemanticsTag(\'${tag.name}\')').join(', ')}]';
557 558
  }

559 560 561 562 563 564 565 566 567
  static String _actionsToSemanticsActionExpression(dynamic actions) {
    Iterable<SemanticsAction> list;
    if (actions is int) {
      list = SemanticsAction.values.values
          .where((SemanticsAction action) => (action.index & actions) != 0);
    } else {
      list = actions;
    }
    return '<SemanticsAction>[${list.join(', ')}]';
568 569 570 571
  }

  /// Recursively generates [TestSemantics] code for [node] and its children,
  /// indenting the expression by `indentAmount`.
572
  static String _generateSemanticsTestForNode(SemanticsNode node, int indentAmount, DebugSemanticsDumpOrder childOrder) {
573 574
    if (node == null)
      return 'null';
575
    final String indent = '  ' * indentAmount;
576
    final StringBuffer buf = StringBuffer();
577
    final SemanticsData nodeData = node.getSemanticsData();
578
    final bool isRoot = node.id == 0;
579
    buf.writeln('TestSemantics${isRoot ? '.root': ''}(');
580 581 582 583
    if (!isRoot)
      buf.writeln('  id: ${node.id},');
    if (nodeData.tags != null)
      buf.writeln('  tags: ${_tagsToSemanticsTagExpression(nodeData.tags)},');
584
    if (nodeData.flags != 0)
585
      buf.writeln('  flags: ${_flagsToSemanticsFlagExpression(nodeData.flags)},');
586
    if (nodeData.actions != 0)
587
      buf.writeln('  actions: ${_actionsToSemanticsActionExpression(nodeData.actions)},');
588 589
    if (node.label != null && node.label.isNotEmpty) {
      final String escapedLabel = node.label.replaceAll('\n', r'\n');
590
      if (escapedLabel != node.label) {
591 592 593 594 595
        buf.writeln('  label: r\'$escapedLabel\',');
      } else {
        buf.writeln('  label: \'$escapedLabel\',');
      }
    }
596
    if (node.value != null && node.value.isNotEmpty)
597
      buf.writeln('  value: \'${node.value}\',');
598
    if (node.increasedValue != null && node.increasedValue.isNotEmpty)
599
      buf.writeln('  increasedValue: \'${node.increasedValue}\',');
600
    if (node.decreasedValue != null && node.decreasedValue.isNotEmpty)
601
      buf.writeln('  decreasedValue: \'${node.decreasedValue}\',');
602
    if (node.hint != null && node.hint.isNotEmpty)
603
      buf.writeln('  hint: \'${node.hint}\',');
604 605 606 607 608
    if (node.textDirection != null)
      buf.writeln('  textDirection: ${node.textDirection},');

    if (node.hasChildren) {
      buf.writeln('  children: <TestSemantics>[');
609
      for (final SemanticsNode child in node.debugListChildrenInOrder(childOrder)) {
610
        buf
611
          ..write(_generateSemanticsTestForNode(child, 2, childOrder))
612
          ..writeln(',');
613
      }
614 615 616 617
      buf.writeln('  ],');
    }

    buf.write(')');
618
    return buf.toString().split('\n').map<String>((String l) => '$indent$l').join('\n');
619
  }
620 621 622
}

class _HasSemantics extends Matcher {
623
  const _HasSemantics(
624 625 626 627 628 629 630 631 632 633
    this._semantics, {
    @required this.ignoreRect,
    @required this.ignoreTransform,
    @required this.ignoreId,
    @required this.childOrder,
  }) : assert(_semantics != null),
       assert(ignoreRect != null),
       assert(ignoreId != null),
       assert(ignoreTransform != null),
       assert(childOrder != null);
634 635

  final TestSemantics _semantics;
636 637
  final bool ignoreRect;
  final bool ignoreTransform;
638
  final bool ignoreId;
639
  final DebugSemanticsDumpOrder childOrder;
640 641

  @override
642
  bool matches(covariant SemanticsTester item, Map<dynamic, dynamic> matchState) {
643
    final bool doesMatch = _semantics._matches(
644
      item.tester.binding.pipelineOwner.semanticsOwner?.rootSemanticsNode,
645 646 647 648 649 650
      matchState,
      ignoreTransform: ignoreTransform,
      ignoreRect: ignoreRect,
      ignoreId: ignoreId,
      childOrder: childOrder,
    );
651
    if (!doesMatch) {
652
      matchState['would-match'] = item.generateTestSemanticsExpressionForCurrentSemanticsTree(childOrder);
653
    }
654 655 656
    if (item.tester.binding.pipelineOwner.semanticsOwner == null) {
      matchState['additional-notes'] = '(Check that the SemanticsTester has not been disposed early.)';
    }
657
    return doesMatch;
658 659 660 661
  }

  @override
  Description describe(Description description) {
662
    return description.add('semantics node matching:\n$_semantics');
663 664
  }

665
  String _indent(String text) {
666
    return text.toString().trimRight().split('\n').map<String>((String line) => '  $line').join('\n');
667 668
  }

669 670
  @override
  Description describeMismatch(dynamic item, Description mismatchDescription, Map<dynamic, dynamic> matchState, bool verbose) {
671 672 673 674 675 676 677 678 679 680 681 682 683
    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']));
    if (matchState.containsKey('additional-notes')) {
      result = result
        .add('\n')
        .add(matchState['additional-notes']);
    }
    return result;
684 685 686 687
  }
}

/// Asserts that a [SemanticsTester] has a semantics tree that exactly matches the given semantics.
688 689
Matcher hasSemantics(
  TestSemantics semantics, {
690 691 692 693
  bool ignoreRect = false,
  bool ignoreTransform = false,
  bool ignoreId = false,
  DebugSemanticsDumpOrder childOrder = DebugSemanticsDumpOrder.traversalOrder,
694
}) {
695
  return _HasSemantics(
696 697 698 699 700 701 702
    semantics,
    ignoreRect: ignoreRect,
    ignoreTransform: ignoreTransform,
    ignoreId: ignoreId,
    childOrder: childOrder,
  );
}
703

704 705 706
class _IncludesNodeWith extends Matcher {
  const _IncludesNodeWith({
    this.label,
707
    this.value,
708
    this.hint,
Ian Hickson's avatar
Ian Hickson committed
709
    this.textDirection,
710
    this.actions,
711
    this.flags,
712 713 714 715
    this.scrollPosition,
    this.scrollExtentMax,
    this.scrollExtentMin,
}) : assert(label != null || value != null || actions != null || flags != null || scrollPosition != null || scrollExtentMax != null || scrollExtentMin != null);
716

717
  final String label;
718
  final String value;
719
  final String hint;
Ian Hickson's avatar
Ian Hickson committed
720
  final TextDirection textDirection;
721
  final List<SemanticsAction> actions;
722
  final List<SemanticsFlag> flags;
723 724 725
  final double scrollPosition;
  final double scrollExtentMax;
  final double scrollExtentMin;
726 727 728

  @override
  bool matches(covariant SemanticsTester item, Map<dynamic, dynamic> matchState) {
729 730 731
    return item.nodesWith(
      label: label,
      value: value,
732
      hint: hint,
733 734 735
      textDirection: textDirection,
      actions: actions,
      flags: flags,
736 737 738
      scrollPosition: scrollPosition,
      scrollExtentMax: scrollExtentMax,
      scrollExtentMin: scrollExtentMin,
739
    ).isNotEmpty;
740 741
  }

742 743
  @override
  Description describe(Description description) {
744
    return description.add('includes node with $_configAsString');
745 746 747 748
  }

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

  String get _configAsString {
753 754 755
    final List<String> strings = <String>[];
    if (label != null)
      strings.add('label "$label"');
756 757
    if (value != null)
      strings.add('value "$value"');
758 759
    if (hint != null)
      strings.add('hint "$hint"');
760 761 762 763 764 765
    if (textDirection != null)
      strings.add(' (${describeEnum(textDirection)})');
    if (actions != null)
      strings.add('actions "${actions.join(', ')}"');
    if (flags != null)
      strings.add('flags "${flags.join(', ')}"');
766 767 768 769 770 771
    if (scrollPosition != null)
      strings.add('scrollPosition "$scrollPosition"');
    if (scrollExtentMax != null)
      strings.add('scrollExtentMax "$scrollExtentMax"');
    if (scrollExtentMin != null)
      strings.add('scrollExtentMin "$scrollExtentMin"');
772
    return strings.join(', ');
773 774 775
  }
}

Ian Hickson's avatar
Ian Hickson committed
776 777
/// Asserts that a node in the semantics tree of [SemanticsTester] has `label`,
/// `textDirection`, and `actions`.
778
///
Ian Hickson's avatar
Ian Hickson committed
779
/// If null is provided for an argument, it will match against any value.
780 781
Matcher includesNodeWith({
  String label,
782
  String value,
783
  String hint,
784 785
  TextDirection textDirection,
  List<SemanticsAction> actions,
786
  List<SemanticsFlag> flags,
787 788 789
  double scrollPosition,
  double scrollExtentMax,
  double scrollExtentMin,
790
}) {
791
  return _IncludesNodeWith(
Ian Hickson's avatar
Ian Hickson committed
792
    label: label,
793
    value: value,
794
    hint: hint,
Ian Hickson's avatar
Ian Hickson committed
795 796
    textDirection: textDirection,
    actions: actions,
797
    flags: flags,
798 799 800
    scrollPosition: scrollPosition,
    scrollExtentMax: scrollExtentMax,
    scrollExtentMin: scrollExtentMin,
Ian Hickson's avatar
Ian Hickson committed
801 802
  );
}