app_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
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
import 'package:flutter/foundation.dart';
6
import 'package:flutter/material.dart';
7
import 'package:flutter/services.dart';
8
import 'package:flutter_test/flutter_test.dart';
9
import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
10

11 12 13 14 15 16
class TestIntent extends Intent {
  const TestIntent();
}

class TestAction extends Action<Intent> {
  TestAction();
17 18 19 20

  int calls = 0;

  @override
21
  void invoke(Intent intent) {
22 23 24 25
    calls += 1;
  }
}

26
void main() {
27
  testWidgetsWithLeakTracking('WidgetsApp with builder only', (WidgetTester tester) async {
28 29 30 31
    final GlobalKey key = GlobalKey();
    await tester.pumpWidget(
      WidgetsApp(
        key: key,
32
        builder: (BuildContext context, Widget? child) {
33 34 35 36 37 38 39
          return const Placeholder();
        },
        color: const Color(0xFF123456),
      ),
    );
    expect(find.byKey(key), findsOneWidget);
  });
40

41
  testWidgetsWithLeakTracking('WidgetsApp default key bindings', (WidgetTester tester) async {
42
    bool? checked = false;
43 44 45 46
    final GlobalKey key = GlobalKey();
    await tester.pumpWidget(
      WidgetsApp(
        key: key,
47
        builder: (BuildContext context, Widget? child) {
48 49 50 51
          return Material(
            child: Checkbox(
              value: checked,
              autofocus: true,
52
              onChanged: (bool? value) {
53 54 55 56 57 58 59 60 61 62 63 64 65
                checked = value;
              },
            ),
          );
        },
        color: const Color(0xFF123456),
      ),
    );
    await tester.pump(); // Wait for focus to take effect.
    await tester.sendKeyEvent(LogicalKeyboardKey.space);
    await tester.pumpAndSettle();
    // Default key mapping worked.
    expect(checked, isTrue);
66
  });
67

68
  testWidgetsWithLeakTracking('WidgetsApp can override default key bindings', (WidgetTester tester) async {
69
    final TestAction action = TestAction();
70 71
    bool? checked = false;
    final GlobalKey key = GlobalKey();
72 73 74
    await tester.pumpWidget(
      WidgetsApp(
        key: key,
75 76
        actions: <Type, Action<Intent>>{
          TestIntent: action,
77
        },
78 79
        shortcuts: const <ShortcutActivator, Intent> {
          SingleActivator(LogicalKeyboardKey.space): TestIntent(),
80
        },
81
        builder: (BuildContext context, Widget? child) {
82 83 84 85
          return Material(
            child: Checkbox(
              value: checked,
              autofocus: true,
86
              onChanged: (bool? value) {
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104
                checked = value;
              },
            ),
          );
        },
        color: const Color(0xFF123456),
      ),
    );
    await tester.pump();

    await tester.sendKeyEvent(LogicalKeyboardKey.space);
    await tester.pumpAndSettle();
    // Default key mapping was not invoked.
    expect(checked, isFalse);
    // Overridden mapping was invoked.
    expect(action.calls, equals(1));
  });

105
  testWidgetsWithLeakTracking('WidgetsApp default activation key mappings work', (WidgetTester tester) async {
106
    bool? checked = false;
107 108 109

    await tester.pumpWidget(
      WidgetsApp(
110
        builder: (BuildContext context, Widget? child) {
111 112 113 114
          return Material(
            child: Checkbox(
              value: checked,
              autofocus: true,
115
              onChanged: (bool? value) {
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140
                checked = value;
              },
            ),
          );
        },
        color: const Color(0xFF123456),
      ),
    );
    await tester.pump();

    // Test three default buttons for the activation action.
    await tester.sendKeyEvent(LogicalKeyboardKey.space);
    await tester.pumpAndSettle();
    expect(checked, isTrue);

    // Only space is used as an activation key on web.
    if (kIsWeb) {
      return;
    }

    checked = false;
    await tester.sendKeyEvent(LogicalKeyboardKey.enter);
    await tester.pumpAndSettle();
    expect(checked, isTrue);

141 142 143 144 145
    checked = false;
    await tester.sendKeyEvent(LogicalKeyboardKey.numpadEnter);
    await tester.pumpAndSettle();
    expect(checked, isTrue);

146 147 148 149
    checked = false;
    await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonA);
    await tester.pumpAndSettle();
    expect(checked, isTrue);
150
  }, variant: KeySimulatorTransitModeVariant.all());
151

152 153
  group('error control test', () {
    Future<void> expectFlutterError({
154 155 156 157
      required GlobalKey<NavigatorState> key,
      required Widget widget,
      required WidgetTester tester,
      required String errorMessage,
158 159
    }) async {
      await tester.pumpWidget(widget);
160
      late FlutterError error;
161
      try {
162
        key.currentState!.pushNamed('/path');
163 164 165 166 167 168 169 170 171
      } on FlutterError catch (e) {
        error = e;
      } finally {
        expect(error, isNotNull);
        expect(error, isFlutterError);
        expect(error.toStringDeep(), errorMessage);
      }
    }

172
    testWidgetsWithLeakTracking('push unknown route when onUnknownRoute is null', (WidgetTester tester) async {
173 174 175 176 177 178 179 180 181 182 183 184 185
      final GlobalKey<NavigatorState> key = GlobalKey<NavigatorState>();
      expectFlutterError(
        key: key,
        tester: tester,
        widget: MaterialApp(
          navigatorKey: key,
          home: Container(),
          onGenerateRoute: (_) => null,
        ),
        errorMessage:
          'FlutterError\n'
          '   Could not find a generator for route RouteSettings("/path", null)\n'
          '   in the _WidgetsAppState.\n'
186 187
          '   Make sure your root app widget has provided a way to generate\n'
          '   this route.\n'
188 189 190 191 192 193 194 195 196 197 198 199
          '   Generators for routes are searched for in the following order:\n'
          '    1. For the "/" route, the "home" property, if non-null, is used.\n'
          '    2. Otherwise, the "routes" table is used, if it has an entry for\n'
          '   the route.\n'
          '    3. Otherwise, onGenerateRoute is called. It should return a\n'
          '   non-null value for any valid route not handled by "home" and\n'
          '   "routes".\n'
          '    4. Finally if all else fails onUnknownRoute is called.\n'
          '   Unfortunately, onUnknownRoute was not set.\n',
      );
    });

200
    testWidgetsWithLeakTracking('push unknown route when onUnknownRoute returns null', (WidgetTester tester) async {
201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220
      final GlobalKey<NavigatorState> key = GlobalKey<NavigatorState>();
      expectFlutterError(
        key: key,
        tester: tester,
        widget: MaterialApp(
          navigatorKey: key,
          home: Container(),
          onGenerateRoute: (_) => null,
          onUnknownRoute: (_) => null,
        ),
        errorMessage:
          'FlutterError\n'
          '   The onUnknownRoute callback returned null.\n'
          '   When the _WidgetsAppState requested the route\n'
          '   RouteSettings("/path", null) from its onUnknownRoute callback,\n'
          '   the callback returned null. Such callbacks must never return\n'
          '   null.\n' ,
      );
    });
  });
221

222
  testWidgetsWithLeakTracking('WidgetsApp can customize initial routes', (WidgetTester tester) async {
223 224 225 226 227 228 229 230 231 232 233
    final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
    await tester.pumpWidget(
      WidgetsApp(
        navigatorKey: navigatorKey,
        onGenerateInitialRoutes: (String initialRoute) {
          expect(initialRoute, '/abc');
          return <Route<void>>[
            PageRouteBuilder<void>(
              pageBuilder: (
                BuildContext context,
                Animation<double> animation,
234 235
                Animation<double> secondaryAnimation,
              ) {
236
                return const Text('non-regular page one');
237
              },
238 239 240 241 242
            ),
            PageRouteBuilder<void>(
              pageBuilder: (
                BuildContext context,
                Animation<double> animation,
243 244
                Animation<double> secondaryAnimation,
              ) {
245
                return const Text('non-regular page two');
246
              },
247 248 249 250 251 252 253 254 255
            ),
          ];
        },
        initialRoute: '/abc',
        onGenerateRoute: (RouteSettings settings) {
          return PageRouteBuilder<void>(
            pageBuilder: (
              BuildContext context,
              Animation<double> animation,
256 257
              Animation<double> secondaryAnimation,
            ) {
258
              return const Text('regular page');
259
            },
260 261 262
          );
        },
        color: const Color(0xFF123456),
263
      ),
264 265 266 267
    );
    expect(find.text('non-regular page two'), findsOneWidget);
    expect(find.text('non-regular page one'), findsNothing);
    expect(find.text('regular page'), findsNothing);
268
    navigatorKey.currentState!.pop();
269 270 271 272 273
    await tester.pumpAndSettle();
    expect(find.text('non-regular page two'), findsNothing);
    expect(find.text('non-regular page one'), findsOneWidget);
    expect(find.text('regular page'), findsNothing);
  });
274

275
  testWidgetsWithLeakTracking('WidgetsApp.router works', (WidgetTester tester) async {
276
    final PlatformRouteInformationProvider provider = PlatformRouteInformationProvider(
277 278
      initialRouteInformation: RouteInformation(
        uri: Uri.parse('initial'),
279 280
      ),
    );
281
    addTearDown(provider.dispose);
282 283
    final SimpleNavigatorRouterDelegate delegate = SimpleNavigatorRouterDelegate(
      builder: (BuildContext context, RouteInformation information) {
284
        return Text(information.uri.toString());
285 286
      },
      onPopPage: (Route<void> route, void result, SimpleNavigatorRouterDelegate delegate) {
287 288
        delegate.routeInformation = RouteInformation(
          uri: Uri.parse('popped'),
289 290
        );
        return route.didPop(result);
291
      },
292
    );
293
    addTearDown(delegate.dispose);
294 295 296 297 298 299 300 301 302 303
    await tester.pumpWidget(WidgetsApp.router(
      routeInformationProvider: provider,
      routeInformationParser: SimpleRouteInformationParser(),
      routerDelegate: delegate,
      color: const Color(0xFF123456),
    ));
    expect(find.text('initial'), findsOneWidget);

    // Simulate android back button intent.
    final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('popRoute'));
304
    await tester.binding.defaultBinaryMessenger.handlePlatformMessage('flutter/navigation', message, (_) { });
305
    await tester.pumpAndSettle();
306
    expect(find.text('popped'), findsOneWidget);
307
  });
308 309

  testWidgetsWithLeakTracking('WidgetsApp.router route information parser is optional', (WidgetTester tester) async {
310 311
    final SimpleNavigatorRouterDelegate delegate = SimpleNavigatorRouterDelegate(
      builder: (BuildContext context, RouteInformation information) {
312
        return Text(information.uri.toString());
313 314
      },
      onPopPage: (Route<void> route, void result, SimpleNavigatorRouterDelegate delegate) {
315 316
        delegate.routeInformation = RouteInformation(
          uri: Uri.parse('popped'),
317 318 319 320
        );
        return route.didPop(result);
      },
    );
321
    addTearDown(delegate.dispose);
322
    delegate.routeInformation = RouteInformation(uri: Uri.parse('initial'));
323 324 325 326 327 328 329 330
    await tester.pumpWidget(WidgetsApp.router(
      routerDelegate: delegate,
      color: const Color(0xFF123456),
    ));
    expect(find.text('initial'), findsOneWidget);

    // Simulate android back button intent.
    final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('popRoute'));
331
    await tester.binding.defaultBinaryMessenger.handlePlatformMessage('flutter/navigation', message, (_) { });
332 333
    await tester.pumpAndSettle();
    expect(find.text('popped'), findsOneWidget);
334
  });
335 336

  testWidgetsWithLeakTracking('WidgetsApp.router throw if route information provider is provided but no route information parser', (WidgetTester tester) async {
337 338
    final SimpleNavigatorRouterDelegate delegate = SimpleNavigatorRouterDelegate(
      builder: (BuildContext context, RouteInformation information) {
339
        return Text(information.uri.toString());
340 341
      },
      onPopPage: (Route<void> route, void result, SimpleNavigatorRouterDelegate delegate) {
342 343
        delegate.routeInformation = RouteInformation(
          uri: Uri.parse('popped'),
344 345 346 347
        );
        return route.didPop(result);
      },
    );
348
    addTearDown(delegate.dispose);
349
    delegate.routeInformation = RouteInformation(uri: Uri.parse('initial'));
350
    final PlatformRouteInformationProvider provider = PlatformRouteInformationProvider(
351 352
      initialRouteInformation: RouteInformation(
        uri: Uri.parse('initial'),
353 354
      ),
    );
355
    addTearDown(provider.dispose);
356 357 358 359 360 361 362 363 364
    await expectLater(() async {
      await tester.pumpWidget(WidgetsApp.router(
        routeInformationProvider: provider,
        routerDelegate: delegate,
        color: const Color(0xFF123456),
      ));
    }, throwsAssertionError);
  });

365
  testWidgetsWithLeakTracking('WidgetsApp.router throw if route configuration is provided along with other delegate', (WidgetTester tester) async {
366 367
    final SimpleNavigatorRouterDelegate delegate = SimpleNavigatorRouterDelegate(
      builder: (BuildContext context, RouteInformation information) {
368
        return Text(information.uri.toString());
369 370
      },
      onPopPage: (Route<void> route, void result, SimpleNavigatorRouterDelegate delegate) {
371 372
        delegate.routeInformation = RouteInformation(
          uri: Uri.parse('popped'),
373 374 375 376
        );
        return route.didPop(result);
      },
    );
377
    addTearDown(delegate.dispose);
378
    delegate.routeInformation = RouteInformation(uri: Uri.parse('initial'));
379 380 381 382 383 384 385 386 387 388
    final RouterConfig<RouteInformation> routerConfig = RouterConfig<RouteInformation>(routerDelegate: delegate);
    await expectLater(() async {
      await tester.pumpWidget(WidgetsApp.router(
        routerDelegate: delegate,
        routerConfig: routerConfig,
        color: const Color(0xFF123456),
      ));
    }, throwsAssertionError);
  });

389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404
  testWidgetsWithLeakTracking('WidgetsApp.router router config works', (WidgetTester tester) async {
    final SimpleNavigatorRouterDelegate delegate = SimpleNavigatorRouterDelegate(
      builder: (BuildContext context, RouteInformation information) {
        return Text(information.uri.toString());
      },
      onPopPage: (Route<void> route, void result, SimpleNavigatorRouterDelegate delegate) {
        delegate.routeInformation = RouteInformation(
          uri: Uri.parse('popped'),
        );
        return route.didPop(result);
      },
    );
    addTearDown(delegate.dispose);
    final PlatformRouteInformationProvider provider = PlatformRouteInformationProvider(
      initialRouteInformation: RouteInformation(
        uri: Uri.parse('initial'),
405
      ),
406 407 408 409
    );
    addTearDown(provider.dispose);
    final RouterConfig<RouteInformation> routerConfig = RouterConfig<RouteInformation>(
      routeInformationProvider: provider,
410
      routeInformationParser: SimpleRouteInformationParser(),
411
      routerDelegate: delegate,
412 413 414 415 416 417 418 419 420 421
      backButtonDispatcher: RootBackButtonDispatcher()
    );
    await tester.pumpWidget(WidgetsApp.router(
      routerConfig: routerConfig,
      color: const Color(0xFF123456),
    ));
    expect(find.text('initial'), findsOneWidget);

    // Simulate android back button intent.
    final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('popRoute'));
422
    await tester.binding.defaultBinaryMessenger.handlePlatformMessage('flutter/navigation', message, (_) { });
423
    await tester.pumpAndSettle();
424
    expect(find.text('popped'), findsOneWidget);
425
  });
426 427

  testWidgetsWithLeakTracking('WidgetsApp.router has correct default', (WidgetTester tester) async {
428 429
    final SimpleNavigatorRouterDelegate delegate = SimpleNavigatorRouterDelegate(
      builder: (BuildContext context, RouteInformation information) {
430
        return Text(information.uri.toString());
431
      },
432
      onPopPage: (Route<Object?> route, Object? result, SimpleNavigatorRouterDelegate delegate) => true,
433
    );
434
    addTearDown(delegate.dispose);
435 436 437 438 439 440
    await tester.pumpWidget(WidgetsApp.router(
      routeInformationParser: SimpleRouteInformationParser(),
      routerDelegate: delegate,
      color: const Color(0xFF123456),
    ));
    expect(find.text('/'), findsOneWidget);
441
  });
442 443

  testWidgetsWithLeakTracking('WidgetsApp has correct default ScrollBehavior', (WidgetTester tester) async {
444 445 446 447 448 449 450 451 452 453 454 455
    late BuildContext capturedContext;
    await tester.pumpWidget(
      WidgetsApp(
        builder: (BuildContext context, Widget? child) {
          capturedContext = context;
          return const Placeholder();
        },
        color: const Color(0xFF123456),
      ),
    );
    expect(ScrollConfiguration.of(capturedContext).runtimeType, ScrollBehavior);
  });
456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582

  test('basicLocaleListResolution', () {
    // Matches exactly for language code.
    expect(
      basicLocaleListResolution(
        <Locale>[
          const Locale('zh'),
          const Locale('un'),
          const Locale('en'),
        ],
        <Locale>[
          const Locale('en'),
        ],
      ),
      const Locale('en'),
    );

    // Matches exactly for language code and country code.
    expect(
      basicLocaleListResolution(
        <Locale>[
          const Locale('en'),
          const Locale('en', 'US'),
        ],
        <Locale>[
          const Locale('en', 'US'),
        ],
      ),
      const Locale('en', 'US'),
    );

    // Matches language+script over language+country
    expect(
      basicLocaleListResolution(
        <Locale>[
          const Locale.fromSubtags(
            languageCode: 'zh',
            scriptCode: 'Hant',
            countryCode: 'HK',
          ),
        ],
        <Locale>[
          const Locale.fromSubtags(
            languageCode: 'zh',
            countryCode: 'HK',
          ),
          const Locale.fromSubtags(
            languageCode: 'zh',
            scriptCode: 'Hant',
          ),
        ],
      ),
      const Locale.fromSubtags(
        languageCode: 'zh',
        scriptCode: 'Hant',
      ),
    );

    // Matches exactly for language code, script code and country code.
    expect(
      basicLocaleListResolution(
        <Locale>[
          const Locale.fromSubtags(
            languageCode: 'zh',
          ),
          const Locale.fromSubtags(
            languageCode: 'zh',
            scriptCode: 'Hant',
            countryCode: 'TW',
          ),
        ],
        <Locale>[
          const Locale.fromSubtags(
            languageCode: 'zh',
            scriptCode: 'Hant',
            countryCode: 'TW',
          ),
        ],
      ),
      const Locale.fromSubtags(
        languageCode: 'zh',
        scriptCode: 'Hant',
        countryCode: 'TW',
      ),
    );

    // Selects for country code if the language code is not found in the
    // preferred locales list.
    expect(
      basicLocaleListResolution(
        <Locale>[
          const Locale.fromSubtags(
            languageCode: 'en',
          ),
          const Locale.fromSubtags(
            languageCode: 'ar',
            countryCode: 'tn',
          ),
        ],
        <Locale>[
          const Locale.fromSubtags(
            languageCode: 'fr',
            countryCode: 'tn',
          ),
        ],
      ),
      const Locale.fromSubtags(
        languageCode: 'fr',
        countryCode: 'tn',
      ),
    );

    // Selects first (default) locale when no match at all is found.
    expect(
      basicLocaleListResolution(
        <Locale>[
          const Locale('tn'),
        ],
        <Locale>[
          const Locale('zh'),
          const Locale('un'),
          const Locale('en'),
        ],
      ),
      const Locale('zh'),
    );
  });
583

584
  testWidgetsWithLeakTracking("WidgetsApp reports an exception if the selected locale isn't supported", (WidgetTester tester) async {
585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607
    late final List<Locale>? localesArg;
    late final Iterable<Locale> supportedLocalesArg;
    await tester.pumpWidget(
      MaterialApp( // This uses a MaterialApp because it introduces some actual localizations.
        localeListResolutionCallback: (List<Locale>? locales, Iterable<Locale> supportedLocales) {
          localesArg = locales;
          supportedLocalesArg = supportedLocales;
          return const Locale('C_UTF-8');
        },
        builder: (BuildContext context, Widget? child) => const Placeholder(),
        color: const Color(0xFF000000),
      ),
    );
    if (!kIsWeb) {
      // On web, `flutter test` does not guarantee a particular locale, but
      // when using `flutter_tester`, we guarantee that it's en-US, zh-CN.
      // https://github.com/flutter/flutter/issues/93290
      expect(localesArg, const <Locale>[Locale('en', 'US'), Locale('zh', 'CN')]);
    }
    expect(supportedLocalesArg, const <Locale>[Locale('en', 'US')]);
    expect(tester.takeException(), "Warning: This application's locale, C_UTF-8, is not supported by all of its localization delegates.");
  });

608
  testWidgetsWithLeakTracking("WidgetsApp doesn't have dependency on MediaQuery", (WidgetTester tester) async {
609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633
    int routeBuildCount = 0;

    final Widget widget = WidgetsApp(
      color: const Color.fromARGB(255, 255, 255, 255),
      onGenerateRoute: (_) {
        return PageRouteBuilder<void>(pageBuilder: (_, __, ___) {
          routeBuildCount++;
          return const Placeholder();
        });
      },
    );

    await tester.pumpWidget(
      MediaQuery(data: const MediaQueryData(textScaleFactor: 10), child: widget),
    );

    expect(routeBuildCount, equals(1));

    await tester.pumpWidget(
      MediaQuery(data: const MediaQueryData(textScaleFactor: 20), child: widget),
    );

    expect(routeBuildCount, equals(1));
  });

634
  testWidgetsWithLeakTracking('WidgetsApp provides meta based shortcuts for iOS and macOS', (WidgetTester tester) async {
635
    final FocusNode focusNode = FocusNode();
636 637
    addTearDown(focusNode.dispose);

638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 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 694 695 696 697 698
    final SelectAllSpy selectAllSpy = SelectAllSpy();
    final CopySpy copySpy = CopySpy();
    final PasteSpy pasteSpy = PasteSpy();
    final Map<Type, Action<Intent>> actions = <Type, Action<Intent>>{
      // Copy Paste
      SelectAllTextIntent: selectAllSpy,
      CopySelectionTextIntent: copySpy,
      PasteTextIntent: pasteSpy,
    };
    await tester.pumpWidget(
      WidgetsApp(
        builder: (BuildContext context, Widget? child) {
          return Actions(
            actions: actions,
            child: Focus(
              focusNode: focusNode,
              child: const Placeholder(),
            ),
          );
        },
        color: const Color(0xFF123456),
      ),
    );
    focusNode.requestFocus();
    await tester.pump();
    expect(selectAllSpy.invoked, isFalse);
    expect(copySpy.invoked, isFalse);
    expect(pasteSpy.invoked, isFalse);

    // Select all.
    await tester.sendKeyDownEvent(LogicalKeyboardKey.metaLeft);
    await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA);
    await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA);
    await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft);
    await tester.pump();

    expect(selectAllSpy.invoked, isTrue);
    expect(copySpy.invoked, isFalse);
    expect(pasteSpy.invoked, isFalse);

    // Copy.
    await tester.sendKeyDownEvent(LogicalKeyboardKey.metaLeft);
    await tester.sendKeyDownEvent(LogicalKeyboardKey.keyC);
    await tester.sendKeyUpEvent(LogicalKeyboardKey.keyC);
    await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft);
    await tester.pump();

    expect(selectAllSpy.invoked, isTrue);
    expect(copySpy.invoked, isTrue);
    expect(pasteSpy.invoked, isFalse);

    // Paste.
    await tester.sendKeyDownEvent(LogicalKeyboardKey.metaLeft);
    await tester.sendKeyDownEvent(LogicalKeyboardKey.keyV);
    await tester.sendKeyUpEvent(LogicalKeyboardKey.keyV);
    await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft);
    await tester.pump();

    expect(selectAllSpy.invoked, isTrue);
    expect(copySpy.invoked, isTrue);
    expect(pasteSpy.invoked, isTrue);
699
  }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726

  group('Android Predictive Back', () {
    Future<void> setAppLifeCycleState(AppLifecycleState state) async {
      final ByteData? message = const StringCodec().encodeMessage(state.toString());
      await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
          .handlePlatformMessage('flutter/lifecycle', message, (ByteData? data) {});
    }

    final List<bool> frameworkHandlesBacks = <bool>[];
    setUp(() async {
      frameworkHandlesBacks.clear();
      TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
        .setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async {
          if (methodCall.method == 'SystemNavigator.setFrameworkHandlesBack') {
            expect(methodCall.arguments, isA<bool>());
            frameworkHandlesBacks.add(methodCall.arguments as bool);
          }
          return;
        });
    });

    tearDown(() async {
      TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
          .setMockMethodCallHandler(SystemChannels.platform, null);
      await setAppLifeCycleState(AppLifecycleState.resumed);
    });

727
    testWidgetsWithLeakTracking('WidgetsApp calls setFrameworkHandlesBack only when app is ready', (WidgetTester tester) async {
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 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783
      // Start in the `resumed` state, where setFrameworkHandlesBack should be
      // called like normal.
      await setAppLifeCycleState(AppLifecycleState.resumed);

      late BuildContext currentContext;
      await tester.pumpWidget(
        WidgetsApp(
          color: const Color(0xFF123456),
          builder: (BuildContext context, Widget? child) {
            currentContext = context;
            return const Placeholder();
          },
        ),
      );

      expect(frameworkHandlesBacks, isEmpty);

      const NavigationNotification(canHandlePop: true).dispatch(currentContext);
      await tester.pumpAndSettle();
      expect(frameworkHandlesBacks, isNotEmpty);
      expect(frameworkHandlesBacks.last, isTrue);

      const NavigationNotification(canHandlePop: false).dispatch(currentContext);
      await tester.pumpAndSettle();
      expect(frameworkHandlesBacks.last, isFalse);

      // Set the app state to inactive, where setFrameworkHandlesBack shouldn't
      // be called.
      await setAppLifeCycleState(AppLifecycleState.inactive);

      final int finalCallsLength = frameworkHandlesBacks.length;
      const NavigationNotification(canHandlePop: true).dispatch(currentContext);
      await tester.pumpAndSettle();
      expect(frameworkHandlesBacks, hasLength(finalCallsLength));

      const NavigationNotification(canHandlePop: false).dispatch(currentContext);
      await tester.pumpAndSettle();
      expect(frameworkHandlesBacks, hasLength(finalCallsLength));

      // Set the app state to detached, which also shouldn't call
      // setFrameworkHandlesBack. Must go to paused, then detached.
      await setAppLifeCycleState(AppLifecycleState.paused);
      await setAppLifeCycleState(AppLifecycleState.detached);

      const NavigationNotification(canHandlePop: true).dispatch(currentContext);
      await tester.pumpAndSettle();
      expect(frameworkHandlesBacks, hasLength(finalCallsLength));

      const NavigationNotification(canHandlePop: false).dispatch(currentContext);
      await tester.pumpAndSettle();
      expect(frameworkHandlesBacks, hasLength(finalCallsLength));
    },
      skip: kIsWeb, // [intended] predictive back is only native Android.
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android })
    );
  });
784 785
}

786 787 788
typedef SimpleRouterDelegateBuilder = Widget Function(BuildContext, RouteInformation);
typedef SimpleNavigatorRouterDelegatePopPage<T> = bool Function(Route<T> route, T result, SimpleNavigatorRouterDelegate delegate);

789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812
class SelectAllSpy extends Action<SelectAllTextIntent> {
  bool invoked = false;
  @override
  void invoke(SelectAllTextIntent intent) {
    invoked = true;
  }
}

class CopySpy extends Action<CopySelectionTextIntent> {
  bool invoked = false;
  @override
  void invoke(CopySelectionTextIntent intent) {
    invoked = true;
  }
}

class PasteSpy extends Action<PasteTextIntent> {
  bool invoked = false;
  @override
  void invoke(PasteTextIntent intent) {
    invoked = true;
  }
}

813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828
class SimpleRouteInformationParser extends RouteInformationParser<RouteInformation> {
  SimpleRouteInformationParser();

  @override
  Future<RouteInformation> parseRouteInformation(RouteInformation information) {
    return SynchronousFuture<RouteInformation>(information);
  }

  @override
  RouteInformation restoreRouteInformation(RouteInformation configuration) {
    return configuration;
  }
}

class SimpleNavigatorRouterDelegate extends RouterDelegate<RouteInformation> with PopNavigatorRouterDelegateMixin<RouteInformation>, ChangeNotifier {
  SimpleNavigatorRouterDelegate({
829 830
    required this.builder,
    required this.onPopPage,
831 832 833 834 835 836
  });

  @override
  GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

  RouteInformation get routeInformation => _routeInformation;
837
  late RouteInformation _routeInformation;
838 839 840 841 842
  set routeInformation(RouteInformation newValue) {
    _routeInformation = newValue;
    notifyListeners();
  }

843 844
  final SimpleRouterDelegateBuilder builder;
  final SimpleNavigatorRouterDelegatePopPage<void> onPopPage;
845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863

  @override
  Future<void> setNewRoutePath(RouteInformation configuration) {
    _routeInformation = configuration;
    return SynchronousFuture<void>(null);
  }

  bool _handlePopPage(Route<void> route, void data) {
    return onPopPage(route, data, this);
  }

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      onPopPage: _handlePopPage,
      pages: <Page<void>>[
        // We need at least two pages for the pop to propagate through.
        // Otherwise, the navigator will bubble the pop to the system navigator.
864 865
        const MaterialPage<void>(
          child: Text('base'),
866 867
        ),
        MaterialPage<void>(
868
          key: ValueKey<String>(routeInformation.uri.toString()),
869
          child: builder(context, routeInformation),
870
        ),
871 872 873
      ],
    );
  }
874
}