semantics_tester.dart 30.6 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 '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
  /// The test screen's size in physical pixels, typically used as the [rect]
  /// for the node with id zero.
  ///
200 201 202 203
  /// See also:
  ///
  ///  * [new TestSemantics.root], which uses this value to describe the root
  ///    node.
Dan Field's avatar
Dan Field committed
204
  static const Rect rootRect = Rect.fromLTWH(0.0, 0.0, 2400.0, 1800.0);
205 206 207

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

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

217
  /// The elevation of this node relative to the parent node.
218 219 220 221 222 223 224 225 226 227 228 229 230 231
  ///
  /// 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;

232 233 234 235 236 237
  /// 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;

238 239
  final TextSelection textSelection;

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

247 248 249
  /// The children of this node.
  final List<TestSemantics> children;

250 251
  /// The tags of this node.
  final Set<SemanticsTag> tags;
252

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

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

271 272
    final SemanticsData nodeData = node.getSemanticsData();

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

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

Ian Hickson's avatar
Ian Hickson committed
285 286
    if (label != nodeData.label)
      return fail('expected node id $id to have label "$label" but found label "${nodeData.label}".');
287 288
    if (value != nodeData.value)
      return fail('expected node id $id to have value "$value" but found value "${nodeData.value}".');
289 290 291 292
    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}".');
293 294
    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
295 296
    if (textDirection != null && textDirection != nodeData.textDirection)
      return fail('expected node id $id to have textDirection "$textDirection" but found "${nodeData.textDirection}".');
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.');
Ian Hickson's avatar
Ian Hickson committed
299 300 301 302
    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}.');
303 304 305 306 307 308
    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}.');
    }
309
    if (textSelection?.baseOffset != nodeData.textSelection?.baseOffset || textSelection?.extentOffset != nodeData.textSelection?.extentOffset) {
310
      return fail('expected node id $id to have textSelection [${textSelection?.baseOffset}, ${textSelection?.end}] but found: [${nodeData.textSelection?.baseOffset}, ${nodeData.textSelection?.extentOffset}].');
311
    }
312 313 314 315 316 317
    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
318 319 320 321
    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.');

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

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

/// 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();
402 403 404 405 406 407 408 409

    // 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);
410 411 412 413 414 415 416 417
  }

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

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

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

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

    final List<SemanticsNode> result = <SemanticsNode>[];
    bool visit(SemanticsNode node) {
      if (checkNode(node)) {
        result.add(node);
      }
      node.visitChildren(visit);
      return true;
    }
495 496 497 498 499
    if (ancestor != null) {
      visit(ancestor);
    } else {
      visit(tester.binding.pipelineOwner.semanticsOwner.rootSemanticsNode);
    }
500 501
    return result;
  }
502 503 504 505 506 507

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

556 557
  static String _flagsToSemanticsFlagExpression(dynamic flags) {
    Iterable<SemanticsFlag> list;
558
    if (flags is int) {
559 560
      list = SemanticsFlag.values.values
          .where((SemanticsFlag flag) => (flag.index & flags) != 0);
561
    } else {
562
      list = flags as List<SemanticsFlag>;
563
    }
564
    return '<SemanticsFlag>[${list.join(', ')}]';
565 566
  }

567
  static String _tagsToSemanticsTagExpression(Set<SemanticsTag> tags) {
568
    return '<SemanticsTag>[${tags.map<String>((SemanticsTag tag) => 'const SemanticsTag(\'${tag.name}\')').join(', ')}]';
569 570
  }

571 572 573 574 575 576
  static String _actionsToSemanticsActionExpression(dynamic actions) {
    Iterable<SemanticsAction> list;
    if (actions is int) {
      list = SemanticsAction.values.values
          .where((SemanticsAction action) => (action.index & actions) != 0);
    } else {
577
      list = actions as List<SemanticsAction>;
578 579
    }
    return '<SemanticsAction>[${list.join(', ')}]';
580 581 582 583
  }

  /// Recursively generates [TestSemantics] code for [node] and its children,
  /// indenting the expression by `indentAmount`.
584
  static String _generateSemanticsTestForNode(SemanticsNode node, int indentAmount, DebugSemanticsDumpOrder childOrder) {
585 586
    if (node == null)
      return 'null';
587
    final String indent = '  ' * indentAmount;
588
    final StringBuffer buf = StringBuffer();
589
    final SemanticsData nodeData = node.getSemanticsData();
590
    final bool isRoot = node.id == 0;
591
    buf.writeln('TestSemantics${isRoot ? '.root': ''}(');
592 593 594 595
    if (!isRoot)
      buf.writeln('  id: ${node.id},');
    if (nodeData.tags != null)
      buf.writeln('  tags: ${_tagsToSemanticsTagExpression(nodeData.tags)},');
596
    if (nodeData.flags != 0)
597
      buf.writeln('  flags: ${_flagsToSemanticsFlagExpression(nodeData.flags)},');
598
    if (nodeData.actions != 0)
599
      buf.writeln('  actions: ${_actionsToSemanticsActionExpression(nodeData.actions)},');
600 601
    if (node.label != null && node.label.isNotEmpty) {
      final String escapedLabel = node.label.replaceAll('\n', r'\n');
602
      if (escapedLabel != node.label) {
603 604 605 606 607
        buf.writeln('  label: r\'$escapedLabel\',');
      } else {
        buf.writeln('  label: \'$escapedLabel\',');
      }
    }
608
    if (node.value != null && node.value.isNotEmpty)
609
      buf.writeln('  value: \'${node.value}\',');
610
    if (node.increasedValue != null && node.increasedValue.isNotEmpty)
611
      buf.writeln('  increasedValue: \'${node.increasedValue}\',');
612
    if (node.decreasedValue != null && node.decreasedValue.isNotEmpty)
613
      buf.writeln('  decreasedValue: \'${node.decreasedValue}\',');
614
    if (node.hint != null && node.hint.isNotEmpty)
615
      buf.writeln('  hint: \'${node.hint}\',');
616 617 618 619
    if (node.textDirection != null)
      buf.writeln('  textDirection: ${node.textDirection},');
    if (node.hasChildren) {
      buf.writeln('  children: <TestSemantics>[');
620
      for (final SemanticsNode child in node.debugListChildrenInOrder(childOrder)) {
621
        buf
622
          ..write(_generateSemanticsTestForNode(child, 2, childOrder))
623
          ..writeln(',');
624
      }
625 626 627 628
      buf.writeln('  ],');
    }

    buf.write(')');
629
    return buf.toString().split('\n').map<String>((String l) => '$indent$l').join('\n');
630
  }
631 632 633
}

class _HasSemantics extends Matcher {
634
  const _HasSemantics(
635 636 637 638 639 640 641 642 643 644
    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);
645 646

  final TestSemantics _semantics;
647 648
  final bool ignoreRect;
  final bool ignoreTransform;
649
  final bool ignoreId;
650
  final DebugSemanticsDumpOrder childOrder;
651 652

  @override
653
  bool matches(covariant SemanticsTester item, Map<dynamic, dynamic> matchState) {
654
    final bool doesMatch = _semantics._matches(
655
      item.tester.binding.pipelineOwner.semanticsOwner?.rootSemanticsNode,
656 657 658 659 660 661
      matchState,
      ignoreTransform: ignoreTransform,
      ignoreRect: ignoreRect,
      ignoreId: ignoreId,
      childOrder: childOrder,
    );
662
    if (!doesMatch) {
663
      matchState['would-match'] = item.generateTestSemanticsExpressionForCurrentSemanticsTree(childOrder);
664
    }
665 666 667
    if (item.tester.binding.pipelineOwner.semanticsOwner == null) {
      matchState['additional-notes'] = '(Check that the SemanticsTester has not been disposed early.)';
    }
668
    return doesMatch;
669 670 671 672
  }

  @override
  Description describe(Description description) {
673
    return description.add('semantics node matching:\n$_semantics');
674 675
  }

676
  String _indent(String text) {
677
    return text.toString().trimRight().split('\n').map<String>((String line) => '  $line').join('\n');
678 679
  }

680 681
  @override
  Description describeMismatch(dynamic item, Description mismatchDescription, Map<dynamic, dynamic> matchState, bool verbose) {
682 683 684 685 686 687
    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')
688
      .add(_indent(matchState['would-match'] as String));
689 690 691
    if (matchState.containsKey('additional-notes')) {
      result = result
        .add('\n')
692
        .add(matchState['additional-notes'] as String);
693 694
    }
    return result;
695 696 697 698
  }
}

/// Asserts that a [SemanticsTester] has a semantics tree that exactly matches the given semantics.
699 700
Matcher hasSemantics(
  TestSemantics semantics, {
701 702 703 704
  bool ignoreRect = false,
  bool ignoreTransform = false,
  bool ignoreId = false,
  DebugSemanticsDumpOrder childOrder = DebugSemanticsDumpOrder.traversalOrder,
705
}) {
706
  return _HasSemantics(
707 708 709 710 711 712 713
    semantics,
    ignoreRect: ignoreRect,
    ignoreTransform: ignoreTransform,
    ignoreId: ignoreId,
    childOrder: childOrder,
  );
}
714

715 716 717
class _IncludesNodeWith extends Matcher {
  const _IncludesNodeWith({
    this.label,
718
    this.value,
719
    this.hint,
Ian Hickson's avatar
Ian Hickson committed
720
    this.textDirection,
721
    this.actions,
722
    this.flags,
723 724 725
    this.scrollPosition,
    this.scrollExtentMax,
    this.scrollExtentMin,
726 727 728 729 730 731 732 733 734 735 736 737 738
    this.maxValueLength,
    this.currentValueLength,
}) : assert(
       label != null ||
       value != null ||
       actions != null ||
       flags != null ||
       scrollPosition != null ||
       scrollExtentMax != null ||
       scrollExtentMin != null ||
       maxValueLength != null ||
       currentValueLength != null
     );
739

740
  final String label;
741
  final String value;
742
  final String hint;
Ian Hickson's avatar
Ian Hickson committed
743
  final TextDirection textDirection;
744
  final List<SemanticsAction> actions;
745
  final List<SemanticsFlag> flags;
746 747 748
  final double scrollPosition;
  final double scrollExtentMax;
  final double scrollExtentMin;
749 750
  final int currentValueLength;
  final int maxValueLength;
751 752 753

  @override
  bool matches(covariant SemanticsTester item, Map<dynamic, dynamic> matchState) {
754 755 756
    return item.nodesWith(
      label: label,
      value: value,
757
      hint: hint,
758 759 760
      textDirection: textDirection,
      actions: actions,
      flags: flags,
761 762 763
      scrollPosition: scrollPosition,
      scrollExtentMax: scrollExtentMax,
      scrollExtentMin: scrollExtentMin,
764 765
      currentValueLength: currentValueLength,
      maxValueLength: maxValueLength,
766
    ).isNotEmpty;
767 768
  }

769 770
  @override
  Description describe(Description description) {
771
    return description.add('includes node with $_configAsString');
772 773 774 775
  }

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

  String get _configAsString {
780 781 782 783 784 785 786 787 788 789
    final List<String> strings = <String>[
      if (label != null) 'label "$label"',
      if (value != null) 'value "$value"',
      if (hint != null) 'hint "$hint"',
      if (textDirection != null) ' (${describeEnum(textDirection)})',
      if (actions != null) 'actions "${actions.join(', ')}"',
      if (flags != null) 'flags "${flags.join(', ')}"',
      if (scrollPosition != null) 'scrollPosition "$scrollPosition"',
      if (scrollExtentMax != null) 'scrollExtentMax "$scrollExtentMax"',
      if (scrollExtentMin != null) 'scrollExtentMin "$scrollExtentMin"',
790 791
      if (currentValueLength != null) 'currentValueLength "$currentValueLength"',
      if (maxValueLength != null) 'maxValueLength "$maxValueLength"',
792
    ];
793
    return strings.join(', ');
794 795 796
  }
}

Ian Hickson's avatar
Ian Hickson committed
797 798
/// Asserts that a node in the semantics tree of [SemanticsTester] has `label`,
/// `textDirection`, and `actions`.
799
///
Ian Hickson's avatar
Ian Hickson committed
800
/// If null is provided for an argument, it will match against any value.
801 802
Matcher includesNodeWith({
  String label,
803
  String value,
804
  String hint,
805 806
  TextDirection textDirection,
  List<SemanticsAction> actions,
807
  List<SemanticsFlag> flags,
808 809 810
  double scrollPosition,
  double scrollExtentMax,
  double scrollExtentMin,
811 812
  int maxValueLength,
  int currentValueLength,
813
}) {
814
  return _IncludesNodeWith(
Ian Hickson's avatar
Ian Hickson committed
815
    label: label,
816
    value: value,
817
    hint: hint,
Ian Hickson's avatar
Ian Hickson committed
818 819
    textDirection: textDirection,
    actions: actions,
820
    flags: flags,
821 822 823
    scrollPosition: scrollPosition,
    scrollExtentMax: scrollExtentMax,
    scrollExtentMin: scrollExtentMin,
824 825
    maxValueLength: maxValueLength,
    currentValueLength: currentValueLength,
Ian Hickson's avatar
Ian Hickson committed
826 827
  );
}