mouse_tracking_test.dart 28.9 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:ui' as ui;
6
import 'dart:ui' show PointerChange;
7

8
import 'package:flutter/cupertino.dart';
9
import 'package:flutter/foundation.dart';
10
import 'package:flutter/rendering.dart';
11
import 'package:flutter/gestures.dart';
12
import 'package:vector_math/vector_math_64.dart' show Matrix4;
13 14

import '../flutter_test_alternative.dart';
15
import './mouse_tracking_test_utils.dart';
16

17
MouseTracker get _mouseTracker => RendererBinding.instance!.mouseTracker;
18

19
typedef SimpleAnnotationFinder = Iterable<TestAnnotationEntry> Function(Offset offset);
20

21
void main() {
22
  final TestMouseTrackerFlutterBinding _binding = TestMouseTrackerFlutterBinding();
23
  void _setUpMouseAnnotationFinder(SimpleAnnotationFinder annotationFinder) {
24 25 26 27
    _binding.setHitTest((BoxHitTestResult result, Offset position) {
      for (final TestAnnotationEntry entry in annotationFinder(position)) {
        result.addWithRawTransform(
          transform: entry.transform,
28
          position: position,
29 30 31 32 33 34 35 36
          hitTest: (BoxHitTestResult result, Offset position) {
            result.add(entry);
            return true;
          },
        );
      }
      return true;
    });
37
  }
38

39 40 41 42 43
  // Set up a trivial test environment that includes one annotation.
  // This annotation records the enter, hover, and exit events it receives to
  // `logEvents`.
  // This annotation also contains a cursor with a value of `testCursor`.
  // The mouse tracker records the cursor requests it receives to `logCursors`.
44 45 46
  TestAnnotationTarget _setUpWithOneAnnotation({
    required List<PointerEvent> logEvents
  }) {
47
    final TestAnnotationTarget oneAnnotation = TestAnnotationTarget(
48 49 50 51 52 53 54 55 56 57 58 59
      onEnter: (PointerEnterEvent event) {
        if (logEvents != null)
          logEvents.add(event);
      },
      onHover: (PointerHoverEvent event) {
        if (logEvents != null)
          logEvents.add(event);
      },
      onExit: (PointerExitEvent event) {
        if (logEvents != null)
          logEvents.add(event);
      },
60 61 62
    );
    _setUpMouseAnnotationFinder(
      (Offset position) sync* {
63
        yield TestAnnotationEntry(oneAnnotation);
64 65
      },
    );
66 67 68 69
    return oneAnnotation;
  }

  void dispatchRemoveDevice([int device = 0]) {
70
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
71 72
      _pointerData(PointerChange.remove, const Offset(0.0, 0.0), device: device),
    ]));
73
  }
74

75
  setUp(() {
76
    _binding.postFrameCallbacks.clear();
77 78
  });

79 80
  final Matrix4 translate10by20 = Matrix4.translationValues(10, 20, 0);

81 82 83 84 85 86 87
  test('MouseTrackerAnnotation has correct toString', () {
    final MouseTrackerAnnotation annotation1 = MouseTrackerAnnotation(
      onEnter: (_) {},
      onExit: (_) {},
    );
    expect(
      annotation1.toString(),
88
      equals('MouseTrackerAnnotation#${shortHash(annotation1)}(callbacks: [enter, exit])'),
89 90 91 92 93 94 95
    );

    const MouseTrackerAnnotation annotation2 = MouseTrackerAnnotation();
    expect(
      annotation2.toString(),
      equals('MouseTrackerAnnotation#${shortHash(annotation2)}(callbacks: <none>)'),
    );
96 97 98 99 100 101 102 103 104

    final MouseTrackerAnnotation annotation3 = MouseTrackerAnnotation(
      onEnter: (_) {},
      cursor: SystemMouseCursors.grab,
    );
    expect(
      annotation3.toString(),
      equals('MouseTrackerAnnotation#${shortHash(annotation3)}(callbacks: [enter], cursor: SystemMouseCursor(grab))'),
    );
105 106 107 108 109 110 111 112 113 114 115 116 117
  });

  test('should detect enter, hover, and exit from Added, Hover, and Removed events', () {
    final List<PointerEvent> events = <PointerEvent>[];
    _setUpWithOneAnnotation(logEvents: events);

    final List<bool> listenerLogs = <bool>[];
    _mouseTracker.addListener(() {
      listenerLogs.add(_mouseTracker.mouseIsConnected);
    });

    expect(_mouseTracker.mouseIsConnected, isFalse);

118
    // Pointer enters the annotation.
119
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
120
      _pointerData(PointerChange.add, const Offset(0.0, 0.0)),
121
    ]));
122
    addTearDown(() => dispatchRemoveDevice());
123

124 125
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerEnterEvent>(const PointerEnterEvent(position: Offset(0.0, 0.0))),
126
    ]));
127 128 129
    expect(listenerLogs, <bool>[true]);
    events.clear();
    listenerLogs.clear();
130

131
    // Pointer hovers the annotation.
132
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
133 134
      _pointerData(PointerChange.hover, const Offset(1.0, 101.0)),
    ]));
135 136
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerHoverEvent>(const PointerHoverEvent(position: Offset(1.0, 101.0))),
137
    ]));
138
    expect(_mouseTracker.mouseIsConnected, isTrue);
139
    expect(listenerLogs, isEmpty);
140
    events.clear();
141

142
    // Pointer is removed while on the annotation.
143
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
144
      _pointerData(PointerChange.remove, const Offset(1.0, 101.0)),
145
    ]));
146 147
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerExitEvent>(const PointerExitEvent(position: Offset(1.0, 101.0))),
148
    ]));
149 150 151
    expect(listenerLogs, <bool>[false]);
    events.clear();
    listenerLogs.clear();
152

153
    // Pointer is added on the annotation.
154
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
155
      _pointerData(PointerChange.add, const Offset(0.0, 301.0)),
156
    ]));
157 158
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerEnterEvent>(const PointerEnterEvent(position: Offset(0.0, 301.0))),
159
    ]));
160 161 162 163 164 165 166 167 168 169 170
    expect(listenerLogs, <bool>[true]);
    events.clear();
    listenerLogs.clear();
  });

  test('should correctly handle multiple devices', () {
    final List<PointerEvent> events = <PointerEvent>[];
    _setUpWithOneAnnotation(logEvents: events);

    expect(_mouseTracker.mouseIsConnected, isFalse);

171
    // The first mouse is added on the annotation.
172
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
173
      _pointerData(PointerChange.add, const Offset(0.0, 0.0)),
174 175
      _pointerData(PointerChange.hover, const Offset(0.0, 1.0)),
    ]));
176 177 178
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerEnterEvent>(const PointerEnterEvent(position: Offset(0.0, 0.0))),
      EventMatcher<PointerHoverEvent>(const PointerHoverEvent(position: Offset(0.0, 1.0))),
179 180 181
    ]));
    expect(_mouseTracker.mouseIsConnected, isTrue);
    events.clear();
182

183
    // The second mouse is added on the annotation.
184
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
185
      _pointerData(PointerChange.add, const Offset(0.0, 401.0), device: 1),
186 187
      _pointerData(PointerChange.hover, const Offset(1.0, 401.0), device: 1),
    ]));
188 189 190
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerEnterEvent>(const PointerEnterEvent(position: Offset(0.0, 401.0), device: 1)),
      EventMatcher<PointerHoverEvent>(const PointerHoverEvent(position: Offset(1.0, 401.0), device: 1)),
191
    ]));
192 193 194
    expect(_mouseTracker.mouseIsConnected, isTrue);
    events.clear();

195
    // The first mouse moves on the annotation.
196
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
197 198
      _pointerData(PointerChange.hover, const Offset(0.0, 101.0)),
    ]));
199 200
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerHoverEvent>(const PointerHoverEvent(position: Offset(0.0, 101.0))),
201 202 203 204
    ]));
    expect(_mouseTracker.mouseIsConnected, isTrue);
    events.clear();

205
    // The second mouse moves on the annotation.
206
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
207 208
      _pointerData(PointerChange.hover, const Offset(1.0, 501.0), device: 1),
    ]));
209 210
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerHoverEvent>(const PointerHoverEvent(position: Offset(1.0, 501.0), device: 1)),
211 212 213 214
    ]));
    expect(_mouseTracker.mouseIsConnected, isTrue);
    events.clear();

215
    // The first mouse is removed while on the annotation.
216
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
217 218
      _pointerData(PointerChange.remove, const Offset(0.0, 101.0)),
    ]));
219 220
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerExitEvent>(const PointerExitEvent(position: Offset(0.0, 101.0))),
221 222 223 224
    ]));
    expect(_mouseTracker.mouseIsConnected, isTrue);
    events.clear();

225
    // The second mouse still moves on the annotation.
226
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
227 228
      _pointerData(PointerChange.hover, const Offset(1.0, 601.0), device: 1),
    ]));
229 230
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerHoverEvent>(const PointerHoverEvent(position: Offset(1.0, 601.0), device: 1)),
231 232 233 234
    ]));
    expect(_mouseTracker.mouseIsConnected, isTrue);
    events.clear();

235
    // The second mouse is removed while on the annotation.
236
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
237 238
      _pointerData(PointerChange.remove, const Offset(1.0, 601.0), device: 1),
    ]));
239 240
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerExitEvent>(const PointerExitEvent(position: Offset(1.0, 601.0), device: 1)),
241 242 243
    ]));
    expect(_mouseTracker.mouseIsConnected, isFalse);
    events.clear();
244
  });
245

246 247 248
  test('should not handle non-hover events', () {
    final List<PointerEvent> events = <PointerEvent>[];
    _setUpWithOneAnnotation(logEvents: events);
249

250
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
251
      _pointerData(PointerChange.add, const Offset(0.0, 101.0)),
252 253
      _pointerData(PointerChange.down, const Offset(0.0, 101.0)),
    ]));
254
    addTearDown(() => dispatchRemoveDevice());
255
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
256 257
      // This Enter event is triggered by the [PointerAddedEvent] The
      // [PointerDownEvent] is ignored by [MouseTracker].
258
      EventMatcher<PointerEnterEvent>(const PointerEnterEvent(position: Offset(0.0, 101.0))),
259
    ]));
260
    events.clear();
261

262
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
263 264
      _pointerData(PointerChange.move, const Offset(0.0, 201.0)),
    ]));
265
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
266
    ]));
267
    events.clear();
268

269
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
270 271
      _pointerData(PointerChange.up, const Offset(0.0, 301.0)),
    ]));
272
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
273 274 275 276
    ]));
    events.clear();
  });

277
  test('should correctly handle when the annotation appears or disappears on the pointer', () {
278
    late bool isInHitRegion;
279
    final List<Object> events = <PointerEvent>[];
280
    final TestAnnotationTarget annotation = TestAnnotationTarget(
281 282 283 284 285 286
      onEnter: (PointerEnterEvent event) => events.add(event),
      onHover: (PointerHoverEvent event) => events.add(event),
      onExit: (PointerExitEvent event) => events.add(event),
    );
    _setUpMouseAnnotationFinder((Offset position) sync* {
      if (isInHitRegion) {
287
        yield TestAnnotationEntry(annotation, Matrix4.translationValues(10, 20, 0));
288 289 290 291 292
      }
    });

    isInHitRegion = false;

293
    // Connect a mouse when there is no annotation.
294
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
295 296
      _pointerData(PointerChange.add, const Offset(0.0, 100.0)),
    ]));
297
    addTearDown(() => dispatchRemoveDevice());
298
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
299 300 301 302
    ]));
    expect(_mouseTracker.mouseIsConnected, isTrue);
    events.clear();

303
    // Adding an annotation should trigger Enter event.
304
    isInHitRegion = true;
305
    _binding.scheduleMouseTrackerPostFrameCheck();
306 307 308
    expect(_binding.postFrameCallbacks, hasLength(1));

    _binding.flushPostFrameCallbacks(Duration.zero);
309 310
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerEnterEvent>(const PointerEnterEvent(position: Offset(0, 100)).transformed(translate10by20)),
311 312 313
    ]));
    events.clear();

314
    // Removing an annotation should trigger events.
315
    isInHitRegion = false;
316 317 318 319
    _binding.scheduleMouseTrackerPostFrameCheck();
    expect(_binding.postFrameCallbacks, hasLength(1));

    _binding.flushPostFrameCallbacks(Duration.zero);
320 321
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerExitEvent>(const PointerExitEvent(position: Offset(0.0, 100.0)).transformed(translate10by20)),
322 323 324 325 326
    ]));
    expect(_binding.postFrameCallbacks, hasLength(0));
  });

  test('should correctly handle when the annotation moves in or out of the pointer', () {
327
    late bool isInHitRegion;
328
    final List<Object> events = <PointerEvent>[];
329
    final TestAnnotationTarget annotation = TestAnnotationTarget(
330 331 332 333 334 335
      onEnter: (PointerEnterEvent event) => events.add(event),
      onHover: (PointerHoverEvent event) => events.add(event),
      onExit: (PointerExitEvent event) => events.add(event),
    );
    _setUpMouseAnnotationFinder((Offset position) sync* {
      if (isInHitRegion) {
336
        yield TestAnnotationEntry(annotation, Matrix4.translationValues(10, 20, 0));
337 338 339 340 341 342
      }
    });

    isInHitRegion = false;

    // Connect a mouse.
343
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
344 345
      _pointerData(PointerChange.add, const Offset(0.0, 100.0)),
    ]));
346
    addTearDown(() => dispatchRemoveDevice());
347 348 349 350 351 352 353 354 355
    events.clear();

    // During a frame, the annotation moves into the pointer.
    isInHitRegion = true;
    expect(_binding.postFrameCallbacks, hasLength(0));
    _binding.scheduleMouseTrackerPostFrameCheck();
    expect(_binding.postFrameCallbacks, hasLength(1));

    _binding.flushPostFrameCallbacks(Duration.zero);
356 357
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerEnterEvent>(const PointerEnterEvent(position: Offset(0.0, 100.0)).transformed(translate10by20)),
358 359 360 361 362 363 364 365 366 367 368 369
    ]));
    events.clear();

    expect(_binding.postFrameCallbacks, hasLength(0));

    // During a frame, the annotation moves out of the pointer.
    isInHitRegion = false;
    expect(_binding.postFrameCallbacks, hasLength(0));
    _binding.scheduleMouseTrackerPostFrameCheck();
    expect(_binding.postFrameCallbacks, hasLength(1));

    _binding.flushPostFrameCallbacks(Duration.zero);
370 371
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerExitEvent>(const PointerExitEvent(position: Offset(0.0, 100.0)).transformed(translate10by20)),
372 373 374 375
    ]));
    expect(_binding.postFrameCallbacks, hasLength(0));
  });

376
  test('should correctly handle when the pointer is added or removed on the annotation', () {
377
    late bool isInHitRegion;
378
    final List<Object> events = <PointerEvent>[];
379
    final TestAnnotationTarget annotation = TestAnnotationTarget(
380 381 382 383 384 385
      onEnter: (PointerEnterEvent event) => events.add(event),
      onHover: (PointerHoverEvent event) => events.add(event),
      onExit: (PointerExitEvent event) => events.add(event),
    );
    _setUpMouseAnnotationFinder((Offset position) sync* {
      if (isInHitRegion) {
386
        yield TestAnnotationEntry(annotation, Matrix4.translationValues(10, 20, 0));
387 388 389 390 391 392 393
      }
    });

    isInHitRegion = false;

    // Connect a mouse in the region. Should trigger Enter.
    isInHitRegion = true;
394
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
395 396 397 398
      _pointerData(PointerChange.add, const Offset(0.0, 100.0)),
    ]));

    expect(_binding.postFrameCallbacks, hasLength(0));
399 400
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerEnterEvent>(const PointerEnterEvent(position: Offset(0.0, 100.0)).transformed(translate10by20)),
401 402 403 404
    ]));
    events.clear();

    // Disconnect the mouse from the region. Should trigger Exit.
405
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
406 407 408
      _pointerData(PointerChange.remove, const Offset(0.0, 100.0)),
    ]));
    expect(_binding.postFrameCallbacks, hasLength(0));
409 410
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerExitEvent>(const PointerExitEvent(position: Offset(0.0, 100.0)).transformed(translate10by20)),
411 412 413 414
    ]));
  });

  test('should correctly handle when the pointer moves in or out of the annotation', () {
415
    late bool isInHitRegion;
416
    final List<Object> events = <PointerEvent>[];
417
    final TestAnnotationTarget annotation = TestAnnotationTarget(
418 419 420 421 422 423
      onEnter: (PointerEnterEvent event) => events.add(event),
      onHover: (PointerHoverEvent event) => events.add(event),
      onExit: (PointerExitEvent event) => events.add(event),
    );
    _setUpMouseAnnotationFinder((Offset position) sync* {
      if (isInHitRegion) {
424
        yield TestAnnotationEntry(annotation, Matrix4.translationValues(10, 20, 0));
425 426 427 428
      }
    });

    isInHitRegion = false;
429
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
430 431
      _pointerData(PointerChange.add, const Offset(200.0, 100.0)),
    ]));
432
    addTearDown(() => dispatchRemoveDevice());
433 434 435 436 437 438

    expect(_binding.postFrameCallbacks, hasLength(0));
    events.clear();

    // Moves the mouse into the region. Should trigger Enter.
    isInHitRegion = true;
439
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
440 441 442
      _pointerData(PointerChange.hover, const Offset(0.0, 100.0)),
    ]));
    expect(_binding.postFrameCallbacks, hasLength(0));
443 444 445
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerEnterEvent>(const PointerEnterEvent(position: Offset(0.0, 100.0)).transformed(translate10by20)),
      EventMatcher<PointerHoverEvent>(const PointerHoverEvent(position: Offset(0.0, 100.0)).transformed(translate10by20)),
446 447 448 449 450
    ]));
    events.clear();

    // Moves the mouse out of the region. Should trigger Exit.
    isInHitRegion = false;
451
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
452 453 454
      _pointerData(PointerChange.hover, const Offset(200.0, 100.0)),
    ]));
    expect(_binding.postFrameCallbacks, hasLength(0));
455 456
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerExitEvent>(const PointerExitEvent(position: Offset(200.0, 100.0)).transformed(translate10by20)),
457 458 459
    ]));
  });

460
  test('should not schedule post-frame callbacks when no mouse is connected', () {
461 462 463
    _setUpMouseAnnotationFinder((Offset position) sync* {
    });

464
    // Connect a touch device, which should not be recognized by MouseTracker
465
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
466 467 468 469 470 471 472
      _pointerData(PointerChange.add, const Offset(0.0, 100.0), kind: PointerDeviceKind.touch),
    ]));
    expect(_mouseTracker.mouseIsConnected, isFalse);

    expect(_binding.postFrameCallbacks, hasLength(0));
  });

473 474 475
  test('should not flip out if not all mouse events are listened to', () {
    bool isInHitRegionOne = true;
    bool isInHitRegionTwo = false;
476
    final TestAnnotationTarget annotation1 = TestAnnotationTarget(
477 478
      onEnter: (PointerEnterEvent event) {}
    );
479
    final TestAnnotationTarget annotation2 = TestAnnotationTarget(
480 481 482 483
      onExit: (PointerExitEvent event) {}
    );
    _setUpMouseAnnotationFinder((Offset position) sync* {
      if (isInHitRegionOne)
484
        yield TestAnnotationEntry(annotation1);
485
      else if (isInHitRegionTwo)
486
        yield TestAnnotationEntry(annotation2);
487 488
    });

489 490
    isInHitRegionOne = false;
    isInHitRegionTwo = true;
491
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
492
      _pointerData(PointerChange.add, const Offset(0.0, 101.0)),
493
      _pointerData(PointerChange.hover, const Offset(1.0, 101.0)),
494
    ]));
495
    addTearDown(() => dispatchRemoveDevice());
496

497
    // Passes if no errors are thrown.
498 499
  });

500 501 502 503 504 505 506 507 508 509
  test('should trigger callbacks between parents and children in correct order', () {
    // This test simulates the scenario of a layer being the child of another.
    //
    //   ———————————
    //   |A        |
    //   |  —————— |
    //   |  |B   | |
    //   |  —————— |
    //   ———————————

510
    late bool isInB;
511
    final List<String> logs = <String>[];
512
    final TestAnnotationTarget annotationA = TestAnnotationTarget(
513 514 515 516
      onEnter: (PointerEnterEvent event) => logs.add('enterA'),
      onExit: (PointerExitEvent event) => logs.add('exitA'),
      onHover: (PointerHoverEvent event) => logs.add('hoverA'),
    );
517
    final TestAnnotationTarget annotationB = TestAnnotationTarget(
518 519 520 521 522
      onEnter: (PointerEnterEvent event) => logs.add('enterB'),
      onExit: (PointerExitEvent event) => logs.add('exitB'),
      onHover: (PointerHoverEvent event) => logs.add('hoverB'),
    );
    _setUpMouseAnnotationFinder((Offset position) sync* {
523
      // Children's annotations come before parents'.
524
      if (isInB) {
525 526
        yield TestAnnotationEntry(annotationB);
        yield TestAnnotationEntry(annotationA);
527 528 529
      }
    });

530
    // Starts out of A.
531
    isInB = false;
532
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
533
      _pointerData(PointerChange.add, const Offset(0.0, 1.0)),
534
    ]));
535
    addTearDown(() => dispatchRemoveDevice());
536 537
    expect(logs, <String>[]);

538
    // Moves into B within one frame.
539
    isInB = true;
540
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
541 542
      _pointerData(PointerChange.hover, const Offset(0.0, 10.0)),
    ]));
543
    expect(logs, <String>['enterA', 'enterB', 'hoverB', 'hoverA']);
544 545
    logs.clear();

546
    // Moves out of A within one frame.
547
    isInB = false;
548
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
549 550 551 552 553 554 555 556 557 558 559 560 561 562
      _pointerData(PointerChange.hover, const Offset(0.0, 20.0)),
    ]));
    expect(logs, <String>['exitB', 'exitA']);
  });

  test('should trigger callbacks between disjoint siblings in correctly order', () {
    // This test simulates the scenario of 2 sibling layers that do not overlap
    // with each other.
    //
    //   ————————  ————————
    //   |A     |  |B     |
    //   |      |  |      |
    //   ————————  ————————

563 564
    late bool isInA;
    late bool isInB;
565
    final List<String> logs = <String>[];
566
    final TestAnnotationTarget annotationA = TestAnnotationTarget(
567 568 569 570
      onEnter: (PointerEnterEvent event) => logs.add('enterA'),
      onExit: (PointerExitEvent event) => logs.add('exitA'),
      onHover: (PointerHoverEvent event) => logs.add('hoverA'),
    );
571
    final TestAnnotationTarget annotationB = TestAnnotationTarget(
572 573 574 575 576 577
      onEnter: (PointerEnterEvent event) => logs.add('enterB'),
      onExit: (PointerExitEvent event) => logs.add('exitB'),
      onHover: (PointerHoverEvent event) => logs.add('hoverB'),
    );
    _setUpMouseAnnotationFinder((Offset position) sync* {
      if (isInA) {
578
        yield TestAnnotationEntry(annotationA);
579
      } else if (isInB) {
580
        yield TestAnnotationEntry(annotationB);
581 582 583
      }
    });

584
    // Starts within A.
585 586
    isInA = true;
    isInB = false;
587
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
588
      _pointerData(PointerChange.add, const Offset(0.0, 1.0)),
589
    ]));
590
    addTearDown(() => dispatchRemoveDevice());
591
    expect(logs, <String>['enterA']);
592 593
    logs.clear();

594
    // Moves into B within one frame.
595 596
    isInA = false;
    isInB = true;
597
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
598 599 600 601 602
      _pointerData(PointerChange.hover, const Offset(0.0, 10.0)),
    ]));
    expect(logs, <String>['exitA', 'enterB', 'hoverB']);
    logs.clear();

603
    // Moves into A within one frame.
604 605
    isInA = true;
    isInB = false;
606
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
607
      _pointerData(PointerChange.hover, const Offset(0.0, 1.0)),
608
    ]));
609
    expect(logs, <String>['exitB', 'enterA', 'hoverA']);
610 611
  });
}
612 613 614 615 616

ui.PointerData _pointerData(
  PointerChange change,
  Offset logicalPosition, {
  int device = 0,
617
  PointerDeviceKind kind = PointerDeviceKind.mouse,
618 619 620 621 622
}) {
  return ui.PointerData(
    change: change,
    physicalX: logicalPosition.dx * ui.window.devicePixelRatio,
    physicalY: logicalPosition.dy * ui.window.devicePixelRatio,
623
    kind: kind,
624 625 626 627
    device: device,
  );
}

628 629 630
class BaseEventMatcher extends Matcher {
  BaseEventMatcher(this.expected)
    : assert(expected != null);
631

632
  final PointerEvent expected;
633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648

  bool _matchesField(Map<dynamic, dynamic> matchState, String field,
      dynamic actual, dynamic expected) {
    if (actual != expected) {
      addStateInfo(matchState, <dynamic, dynamic>{
        'field': field,
        'expected': expected,
        'actual': actual,
      });
      return false;
    }
    return true;
  }

  @override
  bool matches(dynamic untypedItem, Map<dynamic, dynamic> matchState) {
649
    final PointerEvent actual = untypedItem as PointerEvent;
650 651
    if (!(
      _matchesField(matchState, 'kind', actual.kind, PointerDeviceKind.mouse) &&
652 653 654
      _matchesField(matchState, 'position', actual.position, expected.position) &&
      _matchesField(matchState, 'device', actual.device, expected.device) &&
      _matchesField(matchState, 'localPosition', actual.localPosition, expected.localPosition)
655 656 657 658 659 660 661 662 663 664
    )) {
      return false;
    }
    return true;
  }

  @override
  Description describe(Description description) {
    return description
      .add('event (critical fields only) ')
665
      .addDescriptionOf(expected);
666 667 668 669 670 671 672 673 674 675 676 677
  }

  @override
  Description describeMismatch(
    dynamic item,
    Description mismatchDescription,
    Map<dynamic, dynamic> matchState,
    bool verbose,
  ) {
    return mismatchDescription
      .add('has ')
      .addDescriptionOf(matchState['actual'])
678
      .add(" at field `${matchState['field']}`, which doesn't match the expected ")
679 680 681 682
      .addDescriptionOf(matchState['expected']);
  }
}

683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712
class EventMatcher<T extends PointerEvent> extends BaseEventMatcher {
  EventMatcher(T expected) : super(expected);

  @override
  bool matches(dynamic untypedItem, Map<dynamic, dynamic> matchState) {
    if (untypedItem is! T) {
      return false;
    }

    return super.matches(untypedItem, matchState);
  }

  @override
  Description describeMismatch(
    dynamic item,
    Description mismatchDescription,
    Map<dynamic, dynamic> matchState,
    bool verbose,
  ) {
    if (item is! T) {
      return mismatchDescription
        .add('is ')
        .addDescriptionOf(item.runtimeType)
        .add(' and is not a subtype of ')
        .addDescriptionOf(T);
    }
    return super.describeMismatch(item, mismatchDescription, matchState, verbose);
  }
}

713 714 715
class _EventListCriticalFieldsMatcher extends Matcher {
  _EventListCriticalFieldsMatcher(this._expected);

716
  final Iterable<BaseEventMatcher> _expected;
717 718 719 720 721

  @override
  bool matches(dynamic untypedItem, Map<dynamic, dynamic> matchState) {
    if (untypedItem is! Iterable<PointerEvent>)
      return false;
722
    final Iterable<PointerEvent> item = untypedItem;
723 724 725 726
    final Iterator<PointerEvent> iterator = item.iterator;
    if (item.length != _expected.length)
      return false;
    int i = 0;
727
    for (final BaseEventMatcher matcher in _expected) {
728 729 730 731 732 733
      iterator.moveNext();
      final Map<dynamic, dynamic> subState = <dynamic, dynamic>{};
      final PointerEvent actual = iterator.current;
      if (!matcher.matches(actual, subState)) {
        addStateInfo(matchState, <dynamic, dynamic>{
          'index': i,
734
          'expected': matcher.expected,
735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772
          'actual': actual,
          'matcher': matcher,
          'state': subState,
        });
        return false;
      }
      i++;
    }
    return true;
  }

  @override
  Description describe(Description description) {
    return description
      .add('event list (critical fields only) ')
      .addDescriptionOf(_expected);
  }

  @override
  Description describeMismatch(
    dynamic item,
    Description mismatchDescription,
    Map<dynamic, dynamic> matchState,
    bool verbose,
  ) {
    if (item is! Iterable<PointerEvent>) {
      return mismatchDescription
        .add('is type ${item.runtimeType} instead of Iterable<PointerEvent>');
    } else if (item.length != _expected.length) {
      return mismatchDescription
        .add('has length ${item.length} instead of ${_expected.length}');
    } else if (matchState['matcher'] == null) {
      return mismatchDescription
        .add('met unexpected fatal error');
    } else {
      mismatchDescription
        .add('has\n  ')
        .addDescriptionOf(matchState['actual'])
773
        .add("\nat index ${matchState['index']}, which doesn't match\n  ")
774 775 776
        .addDescriptionOf(matchState['expected'])
        .add('\nsince it ');
      final Description subDescription = StringDescription();
777
      final Matcher matcher = matchState['matcher'] as Matcher;
778
      matcher.describeMismatch(matchState['actual'], subDescription,
779
        matchState['state'] as Map<dynamic, dynamic>, verbose);
780 781 782 783 784 785
      mismatchDescription.add(subDescription.toString());
      return mismatchDescription;
    }
  }
}

786
Matcher _equalToEventsOnCriticalFields(List<BaseEventMatcher> source) {
787 788
  return _EventListCriticalFieldsMatcher(source);
}