semantics_tester.dart 30.4 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
        result = false;
        return false;
      }
338 339 340
    }
    if (it.moveNext()) {
      return false;
341
    }
342 343
    return result;
  }
Ian Hickson's avatar
Ian Hickson committed
344 345

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

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

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

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

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

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

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

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

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

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

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

569 570 571 572 573 574 575 576 577
  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(', ')}]';
578 579 580 581
  }

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

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

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

  final TestSemantics _semantics;
645 646
  final bool ignoreRect;
  final bool ignoreTransform;
647
  final bool ignoreId;
648
  final DebugSemanticsDumpOrder childOrder;
649 650

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

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

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

678 679
  @override
  Description describeMismatch(dynamic item, Description mismatchDescription, Map<dynamic, dynamic> matchState, bool verbose) {
680 681 682 683 684 685 686 687 688 689 690 691 692
    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;
693 694 695 696
  }
}

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

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

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

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

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

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

  String get _configAsString {
778 779 780 781 782 783 784 785 786 787
    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"',
788 789
      if (currentValueLength != null) 'currentValueLength "$currentValueLength"',
      if (maxValueLength != null) 'maxValueLength "$maxValueLength"',
790
    ];
791
    return strings.join(', ');
792 793 794
  }
}

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