mouse_tracker_test.dart 29.4 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/gestures.dart';
9
import 'package:flutter/rendering.dart';
10
import 'package:flutter_test/flutter_test.dart';
11

12
import 'mouse_tracker_test_utils.dart';
13

14
MouseTracker get _mouseTracker => RendererBinding.instance.mouseTracker;
15

16
typedef SimpleAnnotationFinder = Iterable<TestAnnotationEntry> Function(Offset offset);
17

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

36 37 38 39 40
  // 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`.
41
  TestAnnotationTarget setUpWithOneAnnotation({
42
    required List<PointerEvent> logEvents,
43
  }) {
44
    final TestAnnotationTarget oneAnnotation = TestAnnotationTarget(
45
      onEnter: (PointerEnterEvent event) {
46
        if (logEvents != null) {
47
          logEvents.add(event);
48
        }
49 50
      },
      onHover: (PointerHoverEvent event) {
51
        if (logEvents != null) {
52
          logEvents.add(event);
53
        }
54 55
      },
      onExit: (PointerExitEvent event) {
56
        if (logEvents != null) {
57
          logEvents.add(event);
58
        }
59
      },
60
    );
61
    setUpMouseAnnotationFinder(
62
      (Offset position) sync* {
63
        yield TestAnnotationEntry(oneAnnotation);
64 65
      },
    );
66 67 68 69
    return oneAnnotation;
  }

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

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

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

81 82
  test('should detect enter, hover, and exit from Added, Hover, and Removed events', () {
    final List<PointerEvent> events = <PointerEvent>[];
83
    setUpWithOneAnnotation(logEvents: events);
84 85 86 87 88 89 90 91

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

    expect(_mouseTracker.mouseIsConnected, isFalse);

92
    // Pointer enters the annotation.
93
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
94
      _pointerData(PointerChange.add, Offset.zero),
95
    ]));
96
    addTearDown(() => dispatchRemoveDevice());
97

98
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
99
      EventMatcher<PointerEnterEvent>(const PointerEnterEvent()),
100
    ]));
101 102 103
    expect(listenerLogs, <bool>[true]);
    events.clear();
    listenerLogs.clear();
104

105
    // Pointer hovers the annotation.
106
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
107 108
      _pointerData(PointerChange.hover, const Offset(1.0, 101.0)),
    ]));
109 110
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerHoverEvent>(const PointerHoverEvent(position: Offset(1.0, 101.0))),
111
    ]));
112
    expect(_mouseTracker.mouseIsConnected, isTrue);
113
    expect(listenerLogs, isEmpty);
114
    events.clear();
115

116
    // Pointer is removed while on the annotation.
117
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
118
      _pointerData(PointerChange.remove, const Offset(1.0, 101.0)),
119
    ]));
120 121
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerExitEvent>(const PointerExitEvent(position: Offset(1.0, 101.0))),
122
    ]));
123 124 125
    expect(listenerLogs, <bool>[false]);
    events.clear();
    listenerLogs.clear();
126

127
    // Pointer is added on the annotation.
128
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
129
      _pointerData(PointerChange.add, const Offset(0.0, 301.0)),
130
    ]));
131 132
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerEnterEvent>(const PointerEnterEvent(position: Offset(0.0, 301.0))),
133
    ]));
134 135 136 137 138
    expect(listenerLogs, <bool>[true]);
    events.clear();
    listenerLogs.clear();
  });

139 140 141 142 143 144 145 146 147 148
  // Regression test for https://github.com/flutter/flutter/issues/90838
  test('should not crash if the first event is a Removed event', () {
    final List<PointerEvent> events = <PointerEvent>[];
    setUpWithOneAnnotation(logEvents: events);
    ui.window.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
      _pointerData(PointerChange.remove, Offset.zero),
    ]));
    events.clear();
  });

149 150
  test('should correctly handle multiple devices', () {
    final List<PointerEvent> events = <PointerEvent>[];
151
    setUpWithOneAnnotation(logEvents: events);
152 153 154

    expect(_mouseTracker.mouseIsConnected, isFalse);

155
    // The first mouse is added on the annotation.
156
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
157
      _pointerData(PointerChange.add, Offset.zero),
158 159
      _pointerData(PointerChange.hover, const Offset(0.0, 1.0)),
    ]));
160
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
161
      EventMatcher<PointerEnterEvent>(const PointerEnterEvent()),
162
      EventMatcher<PointerHoverEvent>(const PointerHoverEvent(position: Offset(0.0, 1.0))),
163 164 165
    ]));
    expect(_mouseTracker.mouseIsConnected, isTrue);
    events.clear();
166

167
    // The second mouse is added on the annotation.
168
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
169
      _pointerData(PointerChange.add, const Offset(0.0, 401.0), device: 1),
170 171
      _pointerData(PointerChange.hover, const Offset(1.0, 401.0), device: 1),
    ]));
172 173 174
    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)),
175
    ]));
176 177 178
    expect(_mouseTracker.mouseIsConnected, isTrue);
    events.clear();

179
    // The first mouse moves on the annotation.
180
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
181 182
      _pointerData(PointerChange.hover, const Offset(0.0, 101.0)),
    ]));
183 184
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerHoverEvent>(const PointerHoverEvent(position: Offset(0.0, 101.0))),
185 186 187 188
    ]));
    expect(_mouseTracker.mouseIsConnected, isTrue);
    events.clear();

189
    // The second mouse moves on the annotation.
190
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
191 192
      _pointerData(PointerChange.hover, const Offset(1.0, 501.0), device: 1),
    ]));
193 194
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerHoverEvent>(const PointerHoverEvent(position: Offset(1.0, 501.0), device: 1)),
195 196 197 198
    ]));
    expect(_mouseTracker.mouseIsConnected, isTrue);
    events.clear();

199
    // The first mouse is removed while on the annotation.
200
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
201 202
      _pointerData(PointerChange.remove, const Offset(0.0, 101.0)),
    ]));
203 204
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerExitEvent>(const PointerExitEvent(position: Offset(0.0, 101.0))),
205 206 207 208
    ]));
    expect(_mouseTracker.mouseIsConnected, isTrue);
    events.clear();

209
    // The second mouse still moves on the annotation.
210
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
211 212
      _pointerData(PointerChange.hover, const Offset(1.0, 601.0), device: 1),
    ]));
213 214
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerHoverEvent>(const PointerHoverEvent(position: Offset(1.0, 601.0), device: 1)),
215 216 217 218
    ]));
    expect(_mouseTracker.mouseIsConnected, isTrue);
    events.clear();

219
    // The second mouse is removed while on the annotation.
220
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
221 222
      _pointerData(PointerChange.remove, const Offset(1.0, 601.0), device: 1),
    ]));
223 224
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerExitEvent>(const PointerExitEvent(position: Offset(1.0, 601.0), device: 1)),
225 226 227
    ]));
    expect(_mouseTracker.mouseIsConnected, isFalse);
    events.clear();
228
  });
229

230 231
  test('should not handle non-hover events', () {
    final List<PointerEvent> events = <PointerEvent>[];
232
    setUpWithOneAnnotation(logEvents: events);
233

234
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
235
      _pointerData(PointerChange.add, const Offset(0.0, 101.0)),
236 237
      _pointerData(PointerChange.down, const Offset(0.0, 101.0)),
    ]));
238
    addTearDown(() => dispatchRemoveDevice());
239
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
240 241
      // This Enter event is triggered by the [PointerAddedEvent] The
      // [PointerDownEvent] is ignored by [MouseTracker].
242
      EventMatcher<PointerEnterEvent>(const PointerEnterEvent(position: Offset(0.0, 101.0))),
243
    ]));
244
    events.clear();
245

246
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
247 248
      _pointerData(PointerChange.move, const Offset(0.0, 201.0)),
    ]));
249
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[]));
250
    events.clear();
251

252
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
253 254
      _pointerData(PointerChange.up, const Offset(0.0, 301.0)),
    ]));
255
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[]));
256 257 258
    events.clear();
  });

259
  test('should correctly handle when the annotation appears or disappears on the pointer', () {
260
    late bool isInHitRegion;
261
    final List<Object> events = <PointerEvent>[];
262
    final TestAnnotationTarget annotation = TestAnnotationTarget(
263 264 265 266
      onEnter: (PointerEnterEvent event) => events.add(event),
      onHover: (PointerHoverEvent event) => events.add(event),
      onExit: (PointerExitEvent event) => events.add(event),
    );
267
    setUpMouseAnnotationFinder((Offset position) sync* {
268
      if (isInHitRegion) {
269
        yield TestAnnotationEntry(annotation, Matrix4.translationValues(10, 20, 0));
270 271 272 273 274
      }
    });

    isInHitRegion = false;

275
    // Connect a mouse when there is no annotation.
276
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
277 278
      _pointerData(PointerChange.add, const Offset(0.0, 100.0)),
    ]));
279
    addTearDown(() => dispatchRemoveDevice());
280
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[]));
281 282 283
    expect(_mouseTracker.mouseIsConnected, isTrue);
    events.clear();

284
    // Adding an annotation should trigger Enter event.
285
    isInHitRegion = true;
286 287
    binding.scheduleMouseTrackerPostFrameCheck();
    expect(binding.postFrameCallbacks, hasLength(1));
288

289
    binding.flushPostFrameCallbacks(Duration.zero);
290 291
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerEnterEvent>(const PointerEnterEvent(position: Offset(0, 100)).transformed(translate10by20)),
292 293 294
    ]));
    events.clear();

295
    // Removing an annotation should trigger events.
296
    isInHitRegion = false;
297 298
    binding.scheduleMouseTrackerPostFrameCheck();
    expect(binding.postFrameCallbacks, hasLength(1));
299

300
    binding.flushPostFrameCallbacks(Duration.zero);
301 302
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerExitEvent>(const PointerExitEvent(position: Offset(0.0, 100.0)).transformed(translate10by20)),
303
    ]));
304
    expect(binding.postFrameCallbacks, hasLength(0));
305 306 307
  });

  test('should correctly handle when the annotation moves in or out of the pointer', () {
308
    late bool isInHitRegion;
309
    final List<Object> events = <PointerEvent>[];
310
    final TestAnnotationTarget annotation = TestAnnotationTarget(
311 312 313 314
      onEnter: (PointerEnterEvent event) => events.add(event),
      onHover: (PointerHoverEvent event) => events.add(event),
      onExit: (PointerExitEvent event) => events.add(event),
    );
315
    setUpMouseAnnotationFinder((Offset position) sync* {
316
      if (isInHitRegion) {
317
        yield TestAnnotationEntry(annotation, Matrix4.translationValues(10, 20, 0));
318 319 320 321 322 323
      }
    });

    isInHitRegion = false;

    // Connect a mouse.
324
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
325 326
      _pointerData(PointerChange.add, const Offset(0.0, 100.0)),
    ]));
327
    addTearDown(() => dispatchRemoveDevice());
328 329 330 331
    events.clear();

    // During a frame, the annotation moves into the pointer.
    isInHitRegion = true;
332 333 334
    expect(binding.postFrameCallbacks, hasLength(0));
    binding.scheduleMouseTrackerPostFrameCheck();
    expect(binding.postFrameCallbacks, hasLength(1));
335

336
    binding.flushPostFrameCallbacks(Duration.zero);
337 338
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerEnterEvent>(const PointerEnterEvent(position: Offset(0.0, 100.0)).transformed(translate10by20)),
339 340 341
    ]));
    events.clear();

342
    expect(binding.postFrameCallbacks, hasLength(0));
343 344 345

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

350
    binding.flushPostFrameCallbacks(Duration.zero);
351 352
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerExitEvent>(const PointerExitEvent(position: Offset(0.0, 100.0)).transformed(translate10by20)),
353
    ]));
354
    expect(binding.postFrameCallbacks, hasLength(0));
355 356
  });

357
  test('should correctly handle when the pointer is added or removed on the annotation', () {
358
    late bool isInHitRegion;
359
    final List<Object> events = <PointerEvent>[];
360
    final TestAnnotationTarget annotation = TestAnnotationTarget(
361 362 363 364
      onEnter: (PointerEnterEvent event) => events.add(event),
      onHover: (PointerHoverEvent event) => events.add(event),
      onExit: (PointerExitEvent event) => events.add(event),
    );
365
    setUpMouseAnnotationFinder((Offset position) sync* {
366
      if (isInHitRegion) {
367
        yield TestAnnotationEntry(annotation, Matrix4.translationValues(10, 20, 0));
368 369 370 371 372 373 374
      }
    });

    isInHitRegion = false;

    // Connect a mouse in the region. Should trigger Enter.
    isInHitRegion = true;
375
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
376 377 378
      _pointerData(PointerChange.add, const Offset(0.0, 100.0)),
    ]));

379
    expect(binding.postFrameCallbacks, hasLength(0));
380 381
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerEnterEvent>(const PointerEnterEvent(position: Offset(0.0, 100.0)).transformed(translate10by20)),
382 383 384 385
    ]));
    events.clear();

    // Disconnect the mouse from the region. Should trigger Exit.
386
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
387 388
      _pointerData(PointerChange.remove, const Offset(0.0, 100.0)),
    ]));
389
    expect(binding.postFrameCallbacks, hasLength(0));
390 391
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerExitEvent>(const PointerExitEvent(position: Offset(0.0, 100.0)).transformed(translate10by20)),
392 393 394 395
    ]));
  });

  test('should correctly handle when the pointer moves in or out of the annotation', () {
396
    late bool isInHitRegion;
397
    final List<Object> events = <PointerEvent>[];
398
    final TestAnnotationTarget annotation = TestAnnotationTarget(
399 400 401 402
      onEnter: (PointerEnterEvent event) => events.add(event),
      onHover: (PointerHoverEvent event) => events.add(event),
      onExit: (PointerExitEvent event) => events.add(event),
    );
403
    setUpMouseAnnotationFinder((Offset position) sync* {
404
      if (isInHitRegion) {
405
        yield TestAnnotationEntry(annotation, Matrix4.translationValues(10, 20, 0));
406 407 408 409
      }
    });

    isInHitRegion = false;
410
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
411 412
      _pointerData(PointerChange.add, const Offset(200.0, 100.0)),
    ]));
413
    addTearDown(() => dispatchRemoveDevice());
414

415
    expect(binding.postFrameCallbacks, hasLength(0));
416 417 418 419
    events.clear();

    // Moves the mouse into the region. Should trigger Enter.
    isInHitRegion = true;
420
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
421 422
      _pointerData(PointerChange.hover, const Offset(0.0, 100.0)),
    ]));
423
    expect(binding.postFrameCallbacks, hasLength(0));
424 425 426
    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)),
427 428 429 430 431
    ]));
    events.clear();

    // Moves the mouse out of the region. Should trigger Exit.
    isInHitRegion = false;
432
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
433 434
      _pointerData(PointerChange.hover, const Offset(200.0, 100.0)),
    ]));
435
    expect(binding.postFrameCallbacks, hasLength(0));
436 437
    expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
      EventMatcher<PointerExitEvent>(const PointerExitEvent(position: Offset(200.0, 100.0)).transformed(translate10by20)),
438 439 440
    ]));
  });

441
  test('should not schedule post-frame callbacks when no mouse is connected', () {
442
    setUpMouseAnnotationFinder((Offset position) sync* {
443 444
    });

445
    // Connect a touch device, which should not be recognized by MouseTracker
446
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
447 448 449 450
      _pointerData(PointerChange.add, const Offset(0.0, 100.0), kind: PointerDeviceKind.touch),
    ]));
    expect(_mouseTracker.mouseIsConnected, isFalse);

451
    expect(binding.postFrameCallbacks, hasLength(0));
452 453
  });

454 455 456
  test('should not flip out if not all mouse events are listened to', () {
    bool isInHitRegionOne = true;
    bool isInHitRegionTwo = false;
457
    final TestAnnotationTarget annotation1 = TestAnnotationTarget(
458
      onEnter: (PointerEnterEvent event) {},
459
    );
460
    final TestAnnotationTarget annotation2 = TestAnnotationTarget(
461
      onExit: (PointerExitEvent event) {},
462
    );
463
    setUpMouseAnnotationFinder((Offset position) sync* {
464
      if (isInHitRegionOne) {
465
        yield TestAnnotationEntry(annotation1);
466
      } else if (isInHitRegionTwo) {
467
        yield TestAnnotationEntry(annotation2);
468
      }
469 470
    });

471 472
    isInHitRegionOne = false;
    isInHitRegionTwo = true;
473
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
474
      _pointerData(PointerChange.add, const Offset(0.0, 101.0)),
475
      _pointerData(PointerChange.hover, const Offset(1.0, 101.0)),
476
    ]));
477
    addTearDown(() => dispatchRemoveDevice());
478

479
    // Passes if no errors are thrown.
480 481
  });

482 483 484 485 486 487 488 489 490 491
  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   | |
    //   |  —————— |
    //   ———————————

492
    late bool isInB;
493
    final List<String> logs = <String>[];
494
    final TestAnnotationTarget annotationA = TestAnnotationTarget(
495 496 497 498
      onEnter: (PointerEnterEvent event) => logs.add('enterA'),
      onExit: (PointerExitEvent event) => logs.add('exitA'),
      onHover: (PointerHoverEvent event) => logs.add('hoverA'),
    );
499
    final TestAnnotationTarget annotationB = TestAnnotationTarget(
500 501 502 503
      onEnter: (PointerEnterEvent event) => logs.add('enterB'),
      onExit: (PointerExitEvent event) => logs.add('exitB'),
      onHover: (PointerHoverEvent event) => logs.add('hoverB'),
    );
504
    setUpMouseAnnotationFinder((Offset position) sync* {
505
      // Children's annotations come before parents'.
506
      if (isInB) {
507 508
        yield TestAnnotationEntry(annotationB);
        yield TestAnnotationEntry(annotationA);
509 510 511
      }
    });

512
    // Starts out of A.
513
    isInB = false;
514
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
515
      _pointerData(PointerChange.add, const Offset(0.0, 1.0)),
516
    ]));
517
    addTearDown(() => dispatchRemoveDevice());
518 519
    expect(logs, <String>[]);

520
    // Moves into B within one frame.
521
    isInB = true;
522
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
523 524
      _pointerData(PointerChange.hover, const Offset(0.0, 10.0)),
    ]));
525
    expect(logs, <String>['enterA', 'enterB', 'hoverB', 'hoverA']);
526 527
    logs.clear();

528
    // Moves out of A within one frame.
529
    isInB = false;
530
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
531 532 533 534 535 536 537 538 539 540 541 542 543 544
      _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     |
    //   |      |  |      |
    //   ————————  ————————

545 546
    late bool isInA;
    late bool isInB;
547
    final List<String> logs = <String>[];
548
    final TestAnnotationTarget annotationA = TestAnnotationTarget(
549 550 551 552
      onEnter: (PointerEnterEvent event) => logs.add('enterA'),
      onExit: (PointerExitEvent event) => logs.add('exitA'),
      onHover: (PointerHoverEvent event) => logs.add('hoverA'),
    );
553
    final TestAnnotationTarget annotationB = TestAnnotationTarget(
554 555 556 557
      onEnter: (PointerEnterEvent event) => logs.add('enterB'),
      onExit: (PointerExitEvent event) => logs.add('exitB'),
      onHover: (PointerHoverEvent event) => logs.add('hoverB'),
    );
558
    setUpMouseAnnotationFinder((Offset position) sync* {
559
      if (isInA) {
560
        yield TestAnnotationEntry(annotationA);
561
      } else if (isInB) {
562
        yield TestAnnotationEntry(annotationB);
563 564 565
      }
    });

566
    // Starts within A.
567 568
    isInA = true;
    isInB = false;
569
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
570
      _pointerData(PointerChange.add, const Offset(0.0, 1.0)),
571
    ]));
572
    addTearDown(() => dispatchRemoveDevice());
573
    expect(logs, <String>['enterA']);
574 575
    logs.clear();

576
    // Moves into B within one frame.
577 578
    isInA = false;
    isInB = true;
579
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
580 581 582 583 584
      _pointerData(PointerChange.hover, const Offset(0.0, 10.0)),
    ]));
    expect(logs, <String>['exitA', 'enterB', 'hoverB']);
    logs.clear();

585
    // Moves into A within one frame.
586 587
    isInA = true;
    isInB = false;
588
    RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
589
      _pointerData(PointerChange.hover, const Offset(0.0, 1.0)),
590
    ]));
591
    expect(logs, <String>['exitB', 'enterA', 'hoverA']);
592 593
  });
}
594 595 596 597 598

ui.PointerData _pointerData(
  PointerChange change,
  Offset logicalPosition, {
  int device = 0,
599
  PointerDeviceKind kind = PointerDeviceKind.mouse,
600 601 602
}) {
  return ui.PointerData(
    change: change,
603 604
    physicalX: logicalPosition.dx * RendererBinding.instance.window.devicePixelRatio,
    physicalY: logicalPosition.dy * RendererBinding.instance.window.devicePixelRatio,
605
    kind: kind,
606 607 608 609
    device: device,
  );
}

610 611 612
class BaseEventMatcher extends Matcher {
  BaseEventMatcher(this.expected)
    : assert(expected != null);
613

614
  final PointerEvent expected;
615

616
  bool _matchesField(Map<dynamic, dynamic> matchState, String field, dynamic actual, dynamic expected) {
617 618 619 620 621 622 623 624 625 626 627 628 629
    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) {
630
    final PointerEvent actual = untypedItem as PointerEvent;
631 632
    if (!(
      _matchesField(matchState, 'kind', actual.kind, PointerDeviceKind.mouse) &&
633 634 635
      _matchesField(matchState, 'position', actual.position, expected.position) &&
      _matchesField(matchState, 'device', actual.device, expected.device) &&
      _matchesField(matchState, 'localPosition', actual.localPosition, expected.localPosition)
636 637 638 639 640 641 642 643 644 645
    )) {
      return false;
    }
    return true;
  }

  @override
  Description describe(Description description) {
    return description
      .add('event (critical fields only) ')
646
      .addDescriptionOf(expected);
647 648 649 650 651 652 653 654 655 656 657 658
  }

  @override
  Description describeMismatch(
    dynamic item,
    Description mismatchDescription,
    Map<dynamic, dynamic> matchState,
    bool verbose,
  ) {
    return mismatchDescription
      .add('has ')
      .addDescriptionOf(matchState['actual'])
659
      .add(" at field `${matchState['field']}`, which doesn't match the expected ")
660 661 662 663
      .addDescriptionOf(matchState['expected']);
  }
}

664
class EventMatcher<T extends PointerEvent> extends BaseEventMatcher {
665
  EventMatcher(T super.expected);
666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693

  @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);
  }
}

694 695 696
class _EventListCriticalFieldsMatcher extends Matcher {
  _EventListCriticalFieldsMatcher(this._expected);

697
  final Iterable<BaseEventMatcher> _expected;
698 699 700

  @override
  bool matches(dynamic untypedItem, Map<dynamic, dynamic> matchState) {
701
    if (untypedItem is! Iterable<PointerEvent>) {
702
      return false;
703
    }
704
    final Iterable<PointerEvent> item = untypedItem;
705
    final Iterator<PointerEvent> iterator = item.iterator;
706
    if (item.length != _expected.length) {
707
      return false;
708
    }
709
    int i = 0;
710
    for (final BaseEventMatcher matcher in _expected) {
711 712 713 714 715 716
      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,
717
          'expected': matcher.expected,
718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755
          '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'])
756
        .add("\nat index ${matchState['index']}, which doesn't match\n  ")
757 758 759
        .addDescriptionOf(matchState['expected'])
        .add('\nsince it ');
      final Description subDescription = StringDescription();
760
      final Matcher matcher = matchState['matcher'] as Matcher;
761 762 763 764 765 766
      matcher.describeMismatch(
        matchState['actual'],
        subDescription,
        matchState['state'] as Map<dynamic, dynamic>,
        verbose,
      );
767 768 769 770 771 772
      mismatchDescription.add(subDescription.toString());
      return mismatchDescription;
    }
  }
}

773
Matcher _equalToEventsOnCriticalFields(List<BaseEventMatcher> source) {
774 775
  return _EventListCriticalFieldsMatcher(source);
}