semantics_tester.dart 15.9 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 6
import 'dart:ui' show SemanticsFlags;

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

export 'package:flutter/rendering.dart' show SemanticsData;

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

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

  /// Creates an object with some test semantics data, with the [id] and [rect]
  /// set to the appropriate values for the root node.
  TestSemantics.root({
    this.flags: 0,
    this.actions: 0,
    this.label: '',
64
    this.value: '',
65 66
    this.increasedValue: '',
    this.decreasedValue: '',
67
    this.hint: '',
Ian Hickson's avatar
Ian Hickson committed
68
    this.textDirection,
69 70
    this.transform,
    this.children: const <TestSemantics>[],
71
    Iterable<SemanticsTag> tags,
72 73 74
  }) : id = 0,
       assert(flags != null),
       assert(label != null),
75 76
       assert(increasedValue != null),
       assert(decreasedValue != null),
77 78
       assert(value != null),
       assert(hint != null),
79
       rect = TestSemantics.rootRect,
80 81
       assert(children != null),
       tags = tags?.toSet() ?? new Set<SemanticsTag>();
82 83 84 85 86 87 88 89 90 91 92

  /// 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({
93
    this.id,
94 95 96
    this.flags: 0,
    this.actions: 0,
    this.label: '',
97 98
    this.hint: '',
    this.value: '',
99 100
    this.increasedValue: '',
    this.decreasedValue: '',
Ian Hickson's avatar
Ian Hickson committed
101
    this.textDirection,
102
    this.rect,
103 104
    Matrix4 transform,
    this.children: const <TestSemantics>[],
105
    Iterable<SemanticsTag> tags,
106 107
  }) : assert(flags != null),
       assert(label != null),
108
       assert(value != null),
109 110
       assert(increasedValue != null),
       assert(decreasedValue != null),
111
       assert(hint != null),
112
       transform = _applyRootChildScale(transform),
113 114
       assert(children != null),
       tags = tags?.toSet() ?? new Set<SemanticsTag>();
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130

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

  /// A bit field of [SemanticsFlags] that apply to this node.
  final int flags;

  /// A bit field of [SemanticsActions] that apply to this node.
  final int actions;

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

131 132 133
  /// A textual description for the value of this node.
  final String value;

134 135 136 137 138 139 140 141
  /// 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;

142 143 144 145
  /// 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
146 147 148 149 150 151 152
  /// 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;

153
  /// The bounding box for this node in its coordinate system.
154
  ///
155 156 157 158 159 160 161
  /// 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.
162 163
  final Rect rect;

164 165 166 167 168 169 170 171 172 173 174
  /// 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.
  static final Rect rootRect = new Rect.fromLTWH(0.0, 0.0, 2400.0, 1800.0);

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

175 176 177
  /// The transform from this node's coordinate system to its parent's coordinate system.
  ///
  /// By default, the transform is null, which represents the identity
178
  /// transformation (i.e., that this node has the same coordinate system as its
179 180 181
  /// parent).
  final Matrix4 transform;

182 183 184 185 186 187 188
  static Matrix4 _applyRootChildScale(Matrix4 transform) {
    final Matrix4 result = new Matrix4.diagonal3Values(3.0, 3.0, 1.0);
    if (transform != null)
      result.multiply(transform);
    return result;
  }

189 190 191
  /// The children of this node.
  final List<TestSemantics> children;

192 193
  /// The tags of this node.
  final Set<SemanticsTag> tags;
194

195
  bool _matches(SemanticsNode node, Map<dynamic, dynamic> matchState, { bool ignoreRect: false, bool ignoreTransform: false, bool ignoreId: false }) {
196
    final SemanticsData nodeData = node.getSemanticsData();
Ian Hickson's avatar
Ian Hickson committed
197 198

    bool fail(String message) {
199
      matchState[TestSemantics] = '$message';
200 201
      return false;
    }
Ian Hickson's avatar
Ian Hickson committed
202 203 204

    if (node == null)
      return fail('could not find node with id $id.');
205
    if (!ignoreId && id != node.id)
Ian Hickson's avatar
Ian Hickson committed
206 207 208 209 210 211 212
      return fail('expected node id $id but found id ${node.id}.');
    if (flags != nodeData.flags)
      return fail('expected node id $id to have flags $flags but found flags ${nodeData.flags}.');
    if (actions != nodeData.actions)
      return fail('expected node id $id to have actions $actions but found actions ${nodeData.actions}.');
    if (label != nodeData.label)
      return fail('expected node id $id to have label "$label" but found label "${nodeData.label}".');
213 214
    if (value != nodeData.value)
      return fail('expected node id $id to have value "$value" but found value "${nodeData.value}".');
215 216 217 218
    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}".');
219 220
    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
221 222
    if (textDirection != null && textDirection != nodeData.textDirection)
      return fail('expected node id $id to have textDirection "$textDirection" but found "${nodeData.textDirection}".');
223
    if ((nodeData.label != '' || nodeData.value != '' || nodeData.hint != '' || node.increasedValue != '' || node.decreasedValue != '') && nodeData.textDirection == null)
224
      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
225 226 227 228 229 230 231 232
    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}.');
    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.');

233 234 235
    if (children.isEmpty)
      return true;
    bool result = true;
236
    final Iterator<TestSemantics> it = children.iterator;
237 238
    node.visitChildren((SemanticsNode node) {
      it.moveNext();
239
      if (!it.current._matches(node, matchState, ignoreRect: ignoreRect, ignoreTransform: ignoreTransform, ignoreId: ignoreId)) {
240 241 242 243 244 245 246
        result = false;
        return false;
      }
      return true;
    });
    return result;
  }
Ian Hickson's avatar
Ian Hickson committed
247 248

  @override
249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271
  String toString([int indentAmount = 0]) {
    final String indent = '  ' * indentAmount;
    final StringBuffer buf = new StringBuffer();
    buf.writeln('$indent$runtimeType {');
    if (id != null)
      buf.writeln('$indent  id: $id');
    buf.writeln('$indent  flags: $flags');
    buf.writeln('$indent  actions: $actions');
    if (label != null)
      buf.writeln('$indent  label: "$label"');
    if (textDirection != null)
      buf.writeln('$indent  textDirection: $textDirection');
    if (rect != null)
      buf.writeln('$indent  rect: $rect');
    if (transform != null)
      buf.writeln('$indent  transform:\n${transform.toString().trim().split('\n').map((String line) => '$indent    $line').join('\n')}');
    buf.writeln('$indent  children: [');
    for (TestSemantics child in children) {
      buf.writeln(child.toString(indentAmount + 2));
    }
    buf.writeln('$indent  ]');
    buf.write('$indent}');
    return buf.toString();
Ian Hickson's avatar
Ian Hickson committed
272
  }
273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300
}

/// 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();
  }

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

  /// Release resources held by this semantics tester.
  ///
  /// Call this function at the end of any test that uses a semantics tester.
  @mustCallSuper
  void dispose() {
    _semanticsHandle.dispose();
    _semanticsHandle = null;
  }

  @override
Ian Hickson's avatar
Ian Hickson committed
301
  String toString() => 'SemanticsTester for ${tester.binding.pipelineOwner.semanticsOwner.rootSemanticsNode}';
302 303 304
}

class _HasSemantics extends Matcher {
305
  const _HasSemantics(this._semantics, { this.ignoreRect: false, this.ignoreTransform: false, this.ignoreId: false }) : assert(_semantics != null), assert(ignoreRect != null), assert(ignoreId != null), assert(ignoreTransform != null);
306 307

  final TestSemantics _semantics;
308 309
  final bool ignoreRect;
  final bool ignoreTransform;
310
  final bool ignoreId;
311 312

  @override
313
  bool matches(covariant SemanticsTester item, Map<dynamic, dynamic> matchState) {
314
    return _semantics._matches(item.tester.binding.pipelineOwner.semanticsOwner.rootSemanticsNode, matchState, ignoreTransform: ignoreTransform, ignoreRect: ignoreRect, ignoreId: ignoreId);
315 316 317 318
  }

  @override
  Description describe(Description description) {
319
    return description.add('semantics node matching:\n$_semantics');
320 321 322 323
  }

  @override
  Description describeMismatch(dynamic item, Description mismatchDescription, Map<dynamic, dynamic> matchState, bool verbose) {
324 325 326 327 328 329
    return mismatchDescription
        .add('${matchState[TestSemantics]}\n')
        .add(
          'Current SemanticsNode tree:\n'
        )
        .add(RendererBinding.instance?.renderView?.debugSemantics?.toStringDeep(childOrder: DebugSemanticsDumpOrder.inverseHitTest));
330 331 332 333
  }
}

/// Asserts that a [SemanticsTester] has a semantics tree that exactly matches the given semantics.
334 335 336
Matcher hasSemantics(TestSemantics semantics, {
  bool ignoreRect: false,
  bool ignoreTransform: false,
337 338
  bool ignoreId: false,
}) => new _HasSemantics(semantics, ignoreRect: ignoreRect, ignoreTransform: ignoreTransform, ignoreId: ignoreId);
339

340 341 342
class _IncludesNodeWith extends Matcher {
  const _IncludesNodeWith({
    this.label,
343
    this.value,
Ian Hickson's avatar
Ian Hickson committed
344
    this.textDirection,
345
    this.actions,
346
    this.flags,
347
}) : assert(label != null || value != null || actions != null || flags != null);
348

349
  final String label;
350
  final String value;
Ian Hickson's avatar
Ian Hickson committed
351
  final TextDirection textDirection;
352
  final List<SemanticsAction> actions;
353
  final List<SemanticsFlags> flags;
354 355 356 357 358 359

  @override
  bool matches(covariant SemanticsTester item, Map<dynamic, dynamic> matchState) {
    bool result = false;
    SemanticsNodeVisitor visitor;
    visitor = (SemanticsNode node) {
360
      if (checkNode(node)) {
361 362 363 364 365 366 367 368 369 370 371
        result = true;
      } else {
        node.visitChildren(visitor);
      }
      return !result;
    };
    final SemanticsNode root = item.tester.binding.pipelineOwner.semanticsOwner.rootSemanticsNode;
    visitor(root);
    return result;
  }

372 373 374
  bool checkNode(SemanticsNode node) {
    if (label != null && node.label != label)
      return false;
375 376
    if (value != null && node.value != value)
      return false;
Ian Hickson's avatar
Ian Hickson committed
377 378
    if (textDirection != null && node.textDirection != textDirection)
      return false;
379 380 381 382 383 384
    if (actions != null) {
      final int expectedActions = actions.fold(0, (int value, SemanticsAction action) => value | action.index);
      final int actualActions = node.getSemanticsData().actions;
      if (expectedActions != actualActions)
        return false;
    }
385 386 387 388 389 390
    if (flags != null) {
      final int expectedFlags = flags.fold(0, (int value, SemanticsFlags flag) => value | flag.index);
      final int actualFlags = node.getSemanticsData().flags;
      if (expectedFlags != actualFlags)
        return false;
    }
391 392 393
    return true;
  }

394 395
  @override
  Description describe(Description description) {
396
    return description.add('includes node with $_configAsString');
397 398 399 400
  }

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

  String get _configAsString {
405 406 407
    final List<String> strings = <String>[];
    if (label != null)
      strings.add('label "$label"');
408 409
    if (value != null)
      strings.add('value "$value"');
410 411 412 413 414 415 416
    if (textDirection != null)
      strings.add(' (${describeEnum(textDirection)})');
    if (actions != null)
      strings.add('actions "${actions.join(', ')}"');
    if (flags != null)
      strings.add('flags "${flags.join(', ')}"');
    return strings.join(', ');
417 418 419
  }
}

Ian Hickson's avatar
Ian Hickson committed
420 421
/// Asserts that a node in the semantics tree of [SemanticsTester] has `label`,
/// `textDirection`, and `actions`.
422
///
Ian Hickson's avatar
Ian Hickson committed
423
/// If null is provided for an argument, it will match against any value.
424 425
Matcher includesNodeWith({
  String label,
426
  String value,
427 428 429 430
  TextDirection textDirection,
  List<SemanticsAction> actions,
  List<SemanticsFlags> flags,
}) {
Ian Hickson's avatar
Ian Hickson committed
431 432
  return new _IncludesNodeWith(
    label: label,
433
    value: value,
Ian Hickson's avatar
Ian Hickson committed
434 435
    textDirection: textDirection,
    actions: actions,
436
    flags: flags,
Ian Hickson's avatar
Ian Hickson committed
437 438
  );
}