// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; typedef PostInvokeCallback = void Function({Action<Intent> action, Intent intent, ActionDispatcher dispatcher}); class TestIntent extends Intent { const TestIntent(); } class SecondTestIntent extends TestIntent { const SecondTestIntent(); } class ThirdTestIntent extends SecondTestIntent { const ThirdTestIntent(); } class TestAction extends CallbackAction<TestIntent> { TestAction({ required OnInvokeCallback onInvoke, }) : assert(onInvoke != null), super(onInvoke: onInvoke); @override bool isEnabled(TestIntent intent) => enabled; bool get enabled => _enabled; bool _enabled = true; set enabled(bool value) { if (_enabled == value) { return; } _enabled = value; notifyActionListeners(); } @override void addActionListener(ActionListenerCallback listener) { super.addActionListener(listener); listeners.add(listener); } @override void removeActionListener(ActionListenerCallback listener) { super.removeActionListener(listener); listeners.remove(listener); } List<ActionListenerCallback> listeners = <ActionListenerCallback>[]; void _testInvoke(TestIntent intent) => invoke(intent); } class TestDispatcher extends ActionDispatcher { const TestDispatcher({this.postInvoke}); final PostInvokeCallback? postInvoke; @override Object? invokeAction(Action<Intent> action, Intent intent, [BuildContext? context]) { final Object? result = super.invokeAction(action, intent, context); postInvoke?.call(action: action, intent: intent, dispatcher: this); return result; } } class TestDispatcher1 extends TestDispatcher { const TestDispatcher1({PostInvokeCallback? postInvoke}) : super(postInvoke: postInvoke); } void main() { testWidgets('CallbackAction passes correct intent when invoked.', (WidgetTester tester) async { late Intent passedIntent; final TestAction action = TestAction(onInvoke: (Intent intent) { passedIntent = intent; return true; }); const TestIntent intent = TestIntent(); action._testInvoke(intent); expect(passedIntent, equals(intent)); }); group(ActionDispatcher, () { testWidgets('ActionDispatcher invokes actions when asked.', (WidgetTester tester) async { await tester.pumpWidget(Container()); bool invoked = false; const ActionDispatcher dispatcher = ActionDispatcher(); final Object? result = dispatcher.invokeAction( TestAction( onInvoke: (Intent intent) { invoked = true; return invoked; }, ), const TestIntent(), ); expect(result, isTrue); expect(invoked, isTrue); }); }); group(Actions, () { Intent? invokedIntent; Action<Intent>? invokedAction; ActionDispatcher? invokedDispatcher; void collect({Action<Intent>? action, Intent? intent, ActionDispatcher? dispatcher}) { invokedIntent = intent; invokedAction = action; invokedDispatcher = dispatcher; } void clear() { invokedIntent = null; invokedAction = null; invokedDispatcher = null; } setUp(clear); testWidgets('Actions widget can invoke actions with default dispatcher', (WidgetTester tester) async { final GlobalKey containerKey = GlobalKey(); bool invoked = false; await tester.pumpWidget( Actions( actions: <Type, Action<Intent>>{ TestIntent: TestAction( onInvoke: (Intent intent) { invoked = true; return invoked; }, ), }, child: Container(key: containerKey), ), ); await tester.pump(); final Object? result = Actions.invoke( containerKey.currentContext!, const TestIntent(), ); expect(result, isTrue); expect(invoked, isTrue); }); testWidgets('Actions widget can invoke actions with default dispatcher and maybeInvoke', (WidgetTester tester) async { final GlobalKey containerKey = GlobalKey(); bool invoked = false; await tester.pumpWidget( Actions( actions: <Type, Action<Intent>>{ TestIntent: TestAction( onInvoke: (Intent intent) { invoked = true; return invoked; }, ), }, child: Container(key: containerKey), ), ); await tester.pump(); final Object? result = Actions.maybeInvoke( containerKey.currentContext!, const TestIntent(), ); expect(result, isTrue); expect(invoked, isTrue); }); testWidgets('maybeInvoke returns null when no action is found', (WidgetTester tester) async { final GlobalKey containerKey = GlobalKey(); bool invoked = false; await tester.pumpWidget( Actions( actions: <Type, Action<Intent>>{ TestIntent: TestAction( onInvoke: (Intent intent) { invoked = true; return invoked; }, ), }, child: Container(key: containerKey), ), ); await tester.pump(); final Object? result = Actions.maybeInvoke( containerKey.currentContext!, DoNothingIntent(), ); expect(result, isNull); expect(invoked, isFalse); }); testWidgets('invoke throws when no action is found', (WidgetTester tester) async { final GlobalKey containerKey = GlobalKey(); bool invoked = false; await tester.pumpWidget( Actions( actions: <Type, Action<Intent>>{ TestIntent: TestAction( onInvoke: (Intent intent) { invoked = true; return invoked; }, ), }, child: Container(key: containerKey), ), ); await tester.pump(); final Object? result = Actions.maybeInvoke( containerKey.currentContext!, DoNothingIntent(), ); expect(result, isNull); expect(invoked, isFalse); }); testWidgets('Actions widget can invoke actions with custom dispatcher', (WidgetTester tester) async { final GlobalKey containerKey = GlobalKey(); bool invoked = false; const TestIntent intent = TestIntent(); final Action<Intent> testAction = TestAction( onInvoke: (Intent intent) { invoked = true; return invoked; }, ); await tester.pumpWidget( Actions( dispatcher: TestDispatcher(postInvoke: collect), actions: <Type, Action<Intent>>{ TestIntent: testAction, }, child: Container(key: containerKey), ), ); await tester.pump(); final Object? result = Actions.invoke<TestIntent>( containerKey.currentContext!, intent, ); expect(result, isTrue); expect(invoked, isTrue); expect(invokedIntent, equals(intent)); }); testWidgets('Actions can invoke actions in ancestor dispatcher', (WidgetTester tester) async { final GlobalKey containerKey = GlobalKey(); bool invoked = false; const TestIntent intent = TestIntent(); final Action<Intent> testAction = TestAction( onInvoke: (Intent intent) { invoked = true; return invoked; }, ); await tester.pumpWidget( Actions( dispatcher: TestDispatcher1(postInvoke: collect), actions: <Type, Action<Intent>>{ TestIntent: testAction, }, child: Actions( dispatcher: TestDispatcher(postInvoke: collect), actions: const <Type, Action<Intent>>{}, child: Container(key: containerKey), ), ), ); await tester.pump(); final Object? result = Actions.invoke<TestIntent>( containerKey.currentContext!, intent, ); expect(result, isTrue); expect(invoked, isTrue); expect(invokedIntent, equals(intent)); expect(invokedAction, equals(testAction)); expect(invokedDispatcher.runtimeType, equals(TestDispatcher1)); }); testWidgets("Actions can invoke actions in ancestor dispatcher if a lower one isn't specified", (WidgetTester tester) async { final GlobalKey containerKey = GlobalKey(); bool invoked = false; const TestIntent intent = TestIntent(); final Action<Intent> testAction = TestAction( onInvoke: (Intent intent) { invoked = true; return invoked; }, ); await tester.pumpWidget( Actions( dispatcher: TestDispatcher1(postInvoke: collect), actions: <Type, Action<Intent>>{ TestIntent: testAction, }, child: Actions( actions: const <Type, Action<Intent>>{}, child: Container(key: containerKey), ), ), ); await tester.pump(); final Object? result = Actions.invoke<TestIntent>( containerKey.currentContext!, intent, ); expect(result, isTrue); expect(invoked, isTrue); expect(invokedIntent, equals(intent)); expect(invokedAction, equals(testAction)); expect(invokedDispatcher.runtimeType, equals(TestDispatcher1)); }); testWidgets('Actions widget can be found with of', (WidgetTester tester) async { final GlobalKey containerKey = GlobalKey(); final ActionDispatcher testDispatcher = TestDispatcher1(postInvoke: collect); await tester.pumpWidget( Actions( dispatcher: testDispatcher, actions: const <Type, Action<Intent>>{}, child: Container(key: containerKey), ), ); await tester.pump(); final ActionDispatcher dispatcher = Actions.of(containerKey.currentContext!); expect(dispatcher, equals(testDispatcher)); }); testWidgets('Action can be found with find', (WidgetTester tester) async { final GlobalKey containerKey = GlobalKey(); final ActionDispatcher testDispatcher = TestDispatcher1(postInvoke: collect); bool invoked = false; final TestAction testAction = TestAction( onInvoke: (Intent intent) { invoked = true; return invoked; }, ); await tester.pumpWidget( Actions( dispatcher: testDispatcher, actions: <Type, Action<Intent>>{ TestIntent: testAction, }, child: Actions( actions: const <Type, Action<Intent>>{}, child: Container(key: containerKey), ), ), ); await tester.pump(); expect(Actions.find<TestIntent>(containerKey.currentContext!), equals(testAction)); expect(() => Actions.find<DoNothingIntent>(containerKey.currentContext!), throwsAssertionError); expect(Actions.maybeFind<DoNothingIntent>(containerKey.currentContext!), isNull); await tester.pumpWidget( Actions( dispatcher: testDispatcher, actions: <Type, Action<Intent>>{ TestIntent: testAction, }, child: Actions( actions: const <Type, Action<Intent>>{}, child: Container(key: containerKey), ), ), ); await tester.pump(); expect(Actions.find<TestIntent>(containerKey.currentContext!), equals(testAction)); expect(() => Actions.find<DoNothingIntent>(containerKey.currentContext!), throwsAssertionError); expect(Actions.maybeFind<DoNothingIntent>(containerKey.currentContext!), isNull); }); testWidgets('FocusableActionDetector keeps track of focus and hover even when disabled.', (WidgetTester tester) async { FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; final GlobalKey containerKey = GlobalKey(); bool invoked = false; const Intent intent = TestIntent(); final FocusNode focusNode = FocusNode(debugLabel: 'Test Node'); final Action<Intent> testAction = TestAction( onInvoke: (Intent intent) { invoked = true; return invoked; }, ); bool hovering = false; bool focusing = false; Future<void> buildTest(bool enabled) async { await tester.pumpWidget( Center( child: Actions( dispatcher: TestDispatcher1(postInvoke: collect), actions: const <Type, Action<Intent>>{}, child: FocusableActionDetector( enabled: enabled, focusNode: focusNode, shortcuts: const <ShortcutActivator, Intent>{ SingleActivator(LogicalKeyboardKey.enter): intent, }, actions: <Type, Action<Intent>>{ TestIntent: testAction, }, onShowHoverHighlight: (bool value) => hovering = value, onShowFocusHighlight: (bool value) => focusing = value, child: SizedBox(width: 100, height: 100, key: containerKey), ), ), ), ); return tester.pump(); } await buildTest(true); focusNode.requestFocus(); await tester.pump(); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); addTearDown(gesture.removePointer); await gesture.moveTo(tester.getCenter(find.byKey(containerKey))); await tester.pump(); await tester.sendKeyEvent(LogicalKeyboardKey.enter); expect(hovering, isTrue); expect(focusing, isTrue); expect(invoked, isTrue); invoked = false; await buildTest(false); expect(hovering, isFalse); expect(focusing, isFalse); await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pump(); expect(invoked, isFalse); await buildTest(true); expect(focusing, isFalse); expect(hovering, isTrue); await buildTest(false); expect(focusing, isFalse); expect(hovering, isFalse); await gesture.moveTo(Offset.zero); await buildTest(true); expect(hovering, isFalse); expect(focusing, isFalse); }); testWidgets('FocusableActionDetector changes mouse cursor when hovered', (WidgetTester tester) async { await tester.pumpWidget( MouseRegion( cursor: SystemMouseCursors.forbidden, child: FocusableActionDetector( mouseCursor: SystemMouseCursors.text, onShowHoverHighlight: (_) {}, onShowFocusHighlight: (_) {}, child: Container(), ), ), ); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1); await gesture.addPointer(location: const Offset(1, 1)); addTearDown(gesture.removePointer); await tester.pump(); expect(RendererBinding.instance!.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.text); // Test default await tester.pumpWidget( MouseRegion( cursor: SystemMouseCursors.forbidden, child: FocusableActionDetector( onShowHoverHighlight: (_) {}, onShowFocusHighlight: (_) {}, child: Container(), ), ), ); expect(RendererBinding.instance!.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.forbidden); }); testWidgets('Actions.invoke returns the value of Action.invoke', (WidgetTester tester) async { final GlobalKey containerKey = GlobalKey(); final Object sentinel = Object(); bool invoked = false; const TestIntent intent = TestIntent(); final Action<Intent> testAction = TestAction( onInvoke: (Intent intent) { invoked = true; return sentinel; }, ); await tester.pumpWidget( Actions( dispatcher: TestDispatcher(postInvoke: collect), actions: <Type, Action<Intent>>{ TestIntent: testAction, }, child: Container(key: containerKey), ), ); await tester.pump(); final Object? result = Actions.invoke<TestIntent>( containerKey.currentContext!, intent, ); expect(identical(result, sentinel), isTrue); expect(invoked, isTrue); }); testWidgets('ContextAction can return null', (WidgetTester tester) async { final GlobalKey containerKey = GlobalKey(); const TestIntent intent = TestIntent(); final TestContextAction testAction = TestContextAction(); await tester.pumpWidget( Actions( dispatcher: TestDispatcher1(postInvoke: collect), actions: <Type, Action<Intent>>{ TestIntent: testAction, }, child: Container(key: containerKey), ), ); await tester.pump(); final Object? result = Actions.invoke<TestIntent>( containerKey.currentContext!, intent, ); expect(result, isNull); expect(invokedIntent, equals(intent)); expect(invokedAction, equals(testAction)); expect(invokedDispatcher.runtimeType, equals(TestDispatcher1)); expect(testAction.capturedContexts.single, containerKey.currentContext); }); testWidgets('Disabled actions stop propagation to an ancestor', (WidgetTester tester) async { final GlobalKey containerKey = GlobalKey(); bool invoked = false; const TestIntent intent = TestIntent(); final TestAction enabledTestAction = TestAction( onInvoke: (Intent intent) { invoked = true; return invoked; }, ); enabledTestAction.enabled = true; final TestAction disabledTestAction = TestAction( onInvoke: (Intent intent) { invoked = true; return invoked; }, ); disabledTestAction.enabled = false; await tester.pumpWidget( Actions( dispatcher: TestDispatcher1(postInvoke: collect), actions: <Type, Action<Intent>>{ TestIntent: enabledTestAction, }, child: Actions( dispatcher: TestDispatcher(postInvoke: collect), actions: <Type, Action<Intent>>{ TestIntent: disabledTestAction, }, child: Container(key: containerKey), ), ), ); await tester.pump(); final Object? result = Actions.invoke<TestIntent>( containerKey.currentContext!, intent, ); expect(result, isNull); expect(invoked, isFalse); expect(invokedIntent, isNull); expect(invokedAction, isNull); expect(invokedDispatcher, isNull); }); }); group('Listening', () { testWidgets('can listen to enabled state of Actions', (WidgetTester tester) async { final GlobalKey containerKey = GlobalKey(); bool invoked1 = false; bool invoked2 = false; bool invoked3 = false; final TestAction action1 = TestAction( onInvoke: (Intent intent) { invoked1 = true; return invoked1; }, ); final TestAction action2 = TestAction( onInvoke: (Intent intent) { invoked2 = true; return invoked2; }, ); final TestAction action3 = TestAction( onInvoke: (Intent intent) { invoked3 = true; return invoked3; }, ); bool enabled1 = true; action1.addActionListener((Action<Intent> action) => enabled1 = action.isEnabled(const TestIntent())); action1.enabled = false; expect(enabled1, isFalse); bool enabled2 = true; action2.addActionListener((Action<Intent> action) => enabled2 = action.isEnabled(const SecondTestIntent())); action2.enabled = false; expect(enabled2, isFalse); bool enabled3 = true; action3.addActionListener((Action<Intent> action) => enabled3 = action.isEnabled(const ThirdTestIntent())); action3.enabled = false; expect(enabled3, isFalse); await tester.pumpWidget( Actions( actions: <Type, Action<TestIntent>>{ TestIntent: action1, SecondTestIntent: action2, }, child: Actions( actions: <Type, Action<TestIntent>>{ ThirdTestIntent: action3, }, child: Container(key: containerKey), ), ), ); Object? result = Actions.maybeInvoke( containerKey.currentContext!, const TestIntent(), ); expect(enabled1, isFalse); expect(result, isNull); expect(invoked1, isFalse); action1.enabled = true; result = Actions.invoke( containerKey.currentContext!, const TestIntent(), ); expect(enabled1, isTrue); expect(result, isTrue); expect(invoked1, isTrue); bool? enabledChanged; await tester.pumpWidget( Actions( actions: <Type, Action<Intent>>{ TestIntent: action1, SecondTestIntent: action2, }, child: ActionListener( listener: (Action<Intent> action) => enabledChanged = action.isEnabled(const ThirdTestIntent()), action: action2, child: Actions( actions: <Type, Action<Intent>>{ ThirdTestIntent: action3, }, child: Container(key: containerKey), ), ), ), ); await tester.pump(); result = Actions.maybeInvoke<TestIntent>( containerKey.currentContext!, const SecondTestIntent(), ); expect(enabledChanged, isNull); expect(enabled2, isFalse); expect(result, isNull); expect(invoked2, isFalse); action2.enabled = true; expect(enabledChanged, isTrue); result = Actions.invoke<TestIntent>( containerKey.currentContext!, const SecondTestIntent(), ); expect(enabled2, isTrue); expect(result, isTrue); expect(invoked2, isTrue); await tester.pumpWidget( Actions( actions: <Type, Action<Intent>>{ TestIntent: action1, }, child: Actions( actions: <Type, Action<Intent>>{ ThirdTestIntent: action3, }, child: Container(key: containerKey), ), ), ); expect(action1.listeners.length, equals(2)); expect(action2.listeners.length, equals(1)); expect(action3.listeners.length, equals(2)); await tester.pumpWidget( Actions( actions: <Type, Action<Intent>>{ TestIntent: action1, ThirdTestIntent: action3, }, child: Container(key: containerKey), ), ); expect(action1.listeners.length, equals(2)); expect(action2.listeners.length, equals(1)); expect(action3.listeners.length, equals(2)); await tester.pumpWidget( Actions( actions: <Type, Action<Intent>>{ TestIntent: action1, }, child: Container(key: containerKey), ), ); expect(action1.listeners.length, equals(2)); expect(action2.listeners.length, equals(1)); expect(action3.listeners.length, equals(1)); await tester.pumpWidget(Container()); await tester.pump(); expect(action1.listeners.length, equals(1)); expect(action2.listeners.length, equals(1)); expect(action3.listeners.length, equals(1)); }); }); group(FocusableActionDetector, () { const Intent intent = TestIntent(); late bool invoked; late bool hovering; late bool focusing; late FocusNode focusNode; late Action<Intent> testAction; Future<void> pumpTest( WidgetTester tester, { bool enabled = true, bool directional = false, bool supplyCallbacks = true, required Key key, }) async { await tester.pumpWidget( MediaQuery( data: MediaQueryData( navigationMode: directional ? NavigationMode.directional : NavigationMode.traditional, ), child: Center( child: Actions( dispatcher: const TestDispatcher1(), actions: const <Type, Action<Intent>>{}, child: FocusableActionDetector( enabled: enabled, focusNode: focusNode, shortcuts: const <ShortcutActivator, Intent>{ SingleActivator(LogicalKeyboardKey.enter): intent, }, actions: <Type, Action<Intent>>{ TestIntent: testAction, }, onShowHoverHighlight: supplyCallbacks ? (bool value) => hovering = value : null, onShowFocusHighlight: supplyCallbacks ? (bool value) => focusing = value : null, child: SizedBox(width: 100, height: 100, key: key), ), ), ), ), ); return tester.pump(); } setUp(() async { invoked = false; hovering = false; focusing = false; focusNode = FocusNode(debugLabel: 'Test Node'); testAction = TestAction( onInvoke: (Intent intent) { invoked = true; return invoked; }, ); }); testWidgets('FocusableActionDetector keeps track of focus and hover even when disabled.', (WidgetTester tester) async { FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; final GlobalKey containerKey = GlobalKey(); await pumpTest(tester, enabled: true, key: containerKey); focusNode.requestFocus(); await tester.pump(); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); addTearDown(gesture.removePointer); await gesture.moveTo(tester.getCenter(find.byKey(containerKey))); await tester.pump(); await tester.sendKeyEvent(LogicalKeyboardKey.enter); expect(hovering, isTrue); expect(focusing, isTrue); expect(invoked, isTrue); invoked = false; await pumpTest(tester, enabled: false, key: containerKey); expect(hovering, isFalse); expect(focusing, isFalse); await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pump(); expect(invoked, isFalse); await pumpTest(tester, enabled: true, key: containerKey); expect(focusing, isFalse); expect(hovering, isTrue); await pumpTest(tester, enabled: false, key: containerKey); expect(focusing, isFalse); expect(hovering, isFalse); await gesture.moveTo(Offset.zero); await pumpTest(tester, enabled: true, key: containerKey); expect(hovering, isFalse); expect(focusing, isFalse); }); testWidgets('FocusableActionDetector shows focus highlight appropriately when focused and disabled', (WidgetTester tester) async { FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; final GlobalKey containerKey = GlobalKey(); await pumpTest(tester, enabled: true, key: containerKey); await tester.pump(); expect(focusing, isFalse); await pumpTest(tester, enabled: true, key: containerKey); focusNode.requestFocus(); await tester.pump(); expect(focusing, isTrue); focusing = false; await pumpTest(tester, enabled: false, key: containerKey); focusNode.requestFocus(); await tester.pump(); expect(focusing, isFalse); await pumpTest(tester, enabled: false, key: containerKey); focusNode.requestFocus(); await tester.pump(); expect(focusing, isFalse); // In directional navigation, focus should show, even if disabled. await pumpTest(tester, enabled: false, key: containerKey, directional: true); focusNode.requestFocus(); await tester.pump(); expect(focusing, isTrue); }); testWidgets('FocusableActionDetector can be used without callbacks', (WidgetTester tester) async { FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; final GlobalKey containerKey = GlobalKey(); await pumpTest(tester, enabled: true, key: containerKey, supplyCallbacks: false); focusNode.requestFocus(); await tester.pump(); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); addTearDown(gesture.removePointer); await gesture.moveTo(tester.getCenter(find.byKey(containerKey))); await tester.pump(); await tester.sendKeyEvent(LogicalKeyboardKey.enter); expect(hovering, isFalse); expect(focusing, isFalse); expect(invoked, isTrue); invoked = false; await pumpTest(tester, enabled: false, key: containerKey, supplyCallbacks: false); expect(hovering, isFalse); expect(focusing, isFalse); await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pump(); expect(invoked, isFalse); await pumpTest(tester, enabled: true, key: containerKey, supplyCallbacks: false); expect(focusing, isFalse); expect(hovering, isFalse); await pumpTest(tester, enabled: false, key: containerKey, supplyCallbacks: false); expect(focusing, isFalse); expect(hovering, isFalse); await gesture.moveTo(Offset.zero); await pumpTest(tester, enabled: true, key: containerKey, supplyCallbacks: false); expect(hovering, isFalse); expect(focusing, isFalse); }); testWidgets( 'FocusableActionDetector can prevent its descendants from being focusable', (WidgetTester tester) async { final FocusNode buttonNode = FocusNode(debugLabel: 'Test'); await tester.pumpWidget( MaterialApp( home: FocusableActionDetector( descendantsAreFocusable: true, child: MaterialButton( focusNode: buttonNode, child: const Text('Test'), onPressed: () {}, ), ), ), ); // Button is focusable expect(buttonNode.hasFocus, isFalse); buttonNode.requestFocus(); await tester.pump(); expect(buttonNode.hasFocus, isTrue); await tester.pumpWidget( MaterialApp( home: FocusableActionDetector( descendantsAreFocusable: false, child: MaterialButton( focusNode: buttonNode, child: const Text('Test'), onPressed: () {}, ), ), ), ); // Button is NOT focusable expect(buttonNode.hasFocus, isFalse); buttonNode.requestFocus(); await tester.pump(); expect(buttonNode.hasFocus, isFalse); }, ); }); group('Diagnostics', () { testWidgets('default Intent debugFillProperties', (WidgetTester tester) async { final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); // ignore: invalid_use_of_protected_member const TestIntent().debugFillProperties(builder); final List<String> description = builder.properties .where((DiagnosticsNode node) { return !node.isFiltered(DiagnosticLevel.info); }) .map((DiagnosticsNode node) => node.toString()) .toList(); expect(description, isEmpty); }); testWidgets('default Actions debugFillProperties', (WidgetTester tester) async { final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); Actions( actions: const <Type, Action<Intent>>{}, dispatcher: const ActionDispatcher(), child: Container(), ).debugFillProperties(builder); final List<String> description = builder.properties .where((DiagnosticsNode node) { return !node.isFiltered(DiagnosticLevel.info); }) .map((DiagnosticsNode node) => node.toString()) .toList(); expect(description.length, equals(2)); expect(description[0], equalsIgnoringHashCodes('dispatcher: ActionDispatcher#00000')); expect(description[1], equals('actions: {}')); }); testWidgets('Actions implements debugFillProperties', (WidgetTester tester) async { final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); Actions( key: const ValueKey<String>('foo'), dispatcher: const ActionDispatcher(), actions: <Type, Action<Intent>>{ TestIntent: TestAction(onInvoke: (Intent intent) => null), }, child: Container(key: const ValueKey<String>('baz')), ).debugFillProperties(builder); final List<String> description = builder.properties .where((DiagnosticsNode node) { return !node.isFiltered(DiagnosticLevel.info); }) .map((DiagnosticsNode node) => node.toString()) .toList(); expect(description.length, equals(2)); expect(description[0], equalsIgnoringHashCodes('dispatcher: ActionDispatcher#00000')); expect(description[1], equalsIgnoringHashCodes('actions: {TestIntent: TestAction#00000}')); }); }); group('Action overriding', () { final List<String> invocations = <String>[]; BuildContext? invokingContext; tearDown(() { invocations.clear(); invokingContext = null; }); testWidgets('Basic usage', (WidgetTester tester) async { late BuildContext invokingContext2; late BuildContext invokingContext3; await tester.pumpWidget( Builder( builder: (BuildContext context1) { return Actions( actions: <Type, Action<Intent>> { LogIntent : Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1'), context: context1), }, child: Builder( builder: (BuildContext context2) { invokingContext2 = context2; return Actions( actions: <Type, Action<Intent>> { LogIntent : Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action2'), context: context2), }, child: Builder( builder: (BuildContext context3) { invokingContext3 = context3; return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3'), context: context3), }, child: Builder( builder: (BuildContext context4) { invokingContext = context4; return const SizedBox(); }, ), ); }, ), ); }, ), ); }, ), ); Actions.invoke(invokingContext!, LogIntent(log: invocations)); expect(invocations, <String>[ 'action1.invokeAsOverride-pre-super', 'action2.invokeAsOverride-pre-super', 'action3.invoke', 'action2.invokeAsOverride-post-super', 'action1.invokeAsOverride-post-super', ]); invocations.clear(); // Invoke from a different (higher) context. Actions.invoke(invokingContext3, LogIntent(log: invocations)); expect(invocations, <String>[ 'action1.invokeAsOverride-pre-super', 'action2.invoke', 'action1.invokeAsOverride-post-super', ]); invocations.clear(); // Invoke from a different (higher) context. Actions.invoke(invokingContext2, LogIntent(log: invocations)); expect(invocations, <String>['action1.invoke']); }); testWidgets('Does not break after use', (WidgetTester tester) async { late BuildContext invokingContext2; late BuildContext invokingContext3; await tester.pumpWidget( Builder( builder: (BuildContext context1) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1'), context: context1), }, child: Builder( builder: (BuildContext context2) { invokingContext2 = context2; return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action2'), context: context2), }, child: Builder( builder: (BuildContext context3) { invokingContext3 = context3; return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3'), context: context3), }, child: Builder( builder: (BuildContext context4) { invokingContext = context4; return const SizedBox(); }, ), ); }, ), ); }, ), ); }, ), ); // Invoke a bunch of times and verify it still produces the same result. final List<BuildContext> randomContexts = <BuildContext>[ invokingContext!, invokingContext2, invokingContext!, invokingContext3, invokingContext3, invokingContext3, invokingContext2, ]; for (final BuildContext randomContext in randomContexts) { Actions.invoke(randomContext, LogIntent(log: invocations)); } invocations.clear(); Actions.invoke(invokingContext!, LogIntent(log: invocations)); expect(invocations, <String>[ 'action1.invokeAsOverride-pre-super', 'action2.invokeAsOverride-pre-super', 'action3.invoke', 'action2.invokeAsOverride-post-super', 'action1.invokeAsOverride-post-super', ]); }); testWidgets('Does not override if not overridable', (WidgetTester tester) async { await tester.pumpWidget( Builder( builder: (BuildContext context1) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1'), context: context1), }, child: Builder( builder: (BuildContext context2) { return Actions( actions: <Type, Action<Intent>> { LogIntent : LogInvocationAction(actionName: 'action2') }, child: Builder( builder: (BuildContext context3) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3'), context: context3), }, child: Builder( builder: (BuildContext context4) { invokingContext = context4; return const SizedBox(); }, ), ); }, ), ); }, ), ); }, ), ); Actions.invoke(invokingContext!, LogIntent(log: invocations)); expect(invocations, <String>[ 'action2.invokeAsOverride-pre-super', 'action3.invoke', 'action2.invokeAsOverride-post-super', ]); }); testWidgets('The final override controls isEnabled', (WidgetTester tester) async { await tester.pumpWidget( Builder( builder: (BuildContext context1) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1'), context: context1), }, child: Builder( builder: (BuildContext context2) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action2', enabled: false), context: context2), }, child: Builder( builder: (BuildContext context3) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3'), context: context3), }, child: Builder( builder: (BuildContext context4) { invokingContext = context4; return const SizedBox(); }, ), ); }, ), ); }, ), ); }, ), ); Actions.invoke(invokingContext!, LogIntent(log: invocations)); expect(invocations, <String>[ 'action1.invokeAsOverride-pre-super', 'action2.invokeAsOverride-pre-super', 'action3.invoke', 'action2.invokeAsOverride-post-super', 'action1.invokeAsOverride-post-super', ]); invocations.clear(); await tester.pumpWidget( Builder( builder: (BuildContext context1) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1', enabled: false), context: context1), }, child: Builder( builder: (BuildContext context2) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action2'), context: context2), }, child: Builder( builder: (BuildContext context3) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3'), context: context3), }, child: Builder( builder: (BuildContext context4) { invokingContext = context4; return const SizedBox(); }, ), ); }, ), ); }, ), ); }, ), ); Actions.invoke(invokingContext!, LogIntent(log: invocations)); expect(invocations, <String>[]); }); testWidgets('The override can choose to defer isActionEnabled to the overridable', (WidgetTester tester) async { await tester.pumpWidget( Builder( builder: (BuildContext context1) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationButDeferIsEnabledAction(actionName: 'action1'), context: context1), }, child: Builder( builder: (BuildContext context2) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action2', enabled: false), context: context2), }, child: Builder( builder: (BuildContext context3) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3'), context: context3), }, child: Builder( builder: (BuildContext context4) { invokingContext = context4; return const SizedBox(); }, ), ); }, ), ); }, ), ); }, ), ); Actions.invoke(invokingContext!, LogIntent(log: invocations)); // Nothing since the final override defers its isActionEnabled state to action2, // which is disabled. expect(invocations, <String>[]); invocations.clear(); await tester.pumpWidget( Builder( builder: (BuildContext context1) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1', enabled: true), context: context1), }, child: Builder( builder: (BuildContext context2) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationButDeferIsEnabledAction(actionName: 'action2'), context: context2), }, child: Builder( builder: (BuildContext context3) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3', enabled: false), context: context3), }, child: Builder( builder: (BuildContext context4) { invokingContext = context4; return const SizedBox(); }, ), ); }, ), ); }, ), ); }, ), ); Actions.invoke(invokingContext!, LogIntent(log: invocations)); // The final override (action1) is enabled so all 3 actions are enabled. expect(invocations, <String>[ 'action1.invokeAsOverride-pre-super', 'action2.invokeAsOverride-pre-super', 'action3.invoke', 'action2.invokeAsOverride-post-super', 'action1.invokeAsOverride-post-super', ]); }); testWidgets('Throws on infinite recursions', (WidgetTester tester) async { late StateSetter setState; BuildContext? action2LookupContext; await tester.pumpWidget( Builder( builder: (BuildContext context1) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1'), context: context1), }, child: StatefulBuilder( builder: (BuildContext context2, StateSetter stateSetter) { setState = stateSetter; return Actions( actions: <Type, Action<Intent>> { if (action2LookupContext != null) LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action2'), context: action2LookupContext!) }, child: Builder( builder: (BuildContext context3) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3'), context: context3), }, child: Builder( builder: (BuildContext context4) { invokingContext = context4; return const SizedBox(); }, ), ); }, ), ); }, ), ); }, ), ); // Let action2 look up its override using a context below itself, so it // will find action3 as its override. expect(tester.takeException(), isNull); setState(() { action2LookupContext = invokingContext; }); await tester.pump(); expect(tester.takeException(), isNull); Object? exception; try { Actions.invoke(invokingContext!, LogIntent(log: invocations)); } catch (e) { exception = e; } expect(exception?.toString(), contains('debugAssertIsEnabledMutuallyRecursive')); }); testWidgets('Throws on invoking invalid override', (WidgetTester tester) async { await tester.pumpWidget( Builder( builder: (BuildContext context) { return Actions( actions: <Type, Action<Intent>> { LogIntent : TestContextAction() }, child: Builder( builder: (BuildContext context) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1'), context: context), }, child: Builder( builder: (BuildContext context1) { invokingContext = context1; return const SizedBox(); }, ), ); }, ), ); }, ), ); Object? exception; try { Actions.invoke(invokingContext!, LogIntent(log: invocations)); } catch (e) { exception = e; } expect( exception?.toString(), contains('cannot be handled by an Action of runtime type TestContextAction.'), ); }); testWidgets('Make an overridable action overridable', (WidgetTester tester) async { await tester.pumpWidget( Builder( builder: (BuildContext context1) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1'), context: context1), }, child: Builder( builder: (BuildContext context2) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action2'), context: context2), }, child: Builder( builder: (BuildContext context3) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable( defaultAction: Action<LogIntent>.overridable( defaultAction: Action<LogIntent>.overridable( defaultAction: LogInvocationAction(actionName: 'action3'), context: context1, ), context: context2, ), context: context3, ) }, child: Builder( builder: (BuildContext context4) { invokingContext = context4; return const SizedBox(); }, ), ); }, ), ); }, ), ); }, ), ); Actions.invoke(invokingContext!, LogIntent(log: invocations)); expect(invocations, <String>[ 'action1.invokeAsOverride-pre-super', 'action2.invokeAsOverride-pre-super', 'action3.invoke', 'action2.invokeAsOverride-post-super', 'action1.invokeAsOverride-post-super', ]); }); testWidgets('Overriding Actions can change the intent', (WidgetTester tester) async { final List<String> newLogChannel = <String>[]; await tester.pumpWidget( Builder( builder: (BuildContext context1) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1'), context: context1), }, child: Builder( builder: (BuildContext context2) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: RedirectOutputAction(actionName: 'action2', newLog: newLogChannel), context: context2), }, child: Builder( builder: (BuildContext context3) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3'), context: context3), }, child: Builder( builder: (BuildContext context4) { invokingContext = context4; return const SizedBox(); }, ), ); }, ), ); }, ), ); }, ), ); Actions.invoke(invokingContext!, LogIntent(log: invocations)); expect(invocations, <String>[ 'action1.invokeAsOverride-pre-super', 'action1.invokeAsOverride-post-super', ]); expect(newLogChannel, <String>[ 'action2.invokeAsOverride-pre-super', 'action3.invoke', 'action2.invokeAsOverride-post-super', ]); }); testWidgets('Override non-context overridable Actions with a ContextAction', (WidgetTester tester) async { await tester.pumpWidget( Builder( builder: (BuildContext context1) { return Actions( actions: <Type, Action<Intent>> { // The default Action is a ContextAction subclass. LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationContextAction(actionName: 'action1'), context: context1), }, child: Builder( builder: (BuildContext context2) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action2', enabled: false), context: context2), }, child: Builder( builder: (BuildContext context3) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3'), context: context3), }, child: Builder( builder: (BuildContext context4) { invokingContext = context4; return const SizedBox(); }, ), ); }, ), ); }, ), ); }, ), ); Actions.invoke(invokingContext!, LogIntent(log: invocations)); expect(invocations, <String>[ 'action1.invokeAsOverride-pre-super', 'action2.invokeAsOverride-pre-super', 'action3.invoke', 'action2.invokeAsOverride-post-super', 'action1.invokeAsOverride-post-super', ]); // Action1 is a ContextAction and action2 & action3 are not. // They should not lose information. expect(LogInvocationContextAction.invokeContext, isNotNull); expect(LogInvocationContextAction.invokeContext, invokingContext); }); testWidgets('Override a ContextAction with a regular Action', (WidgetTester tester) async { await tester.pumpWidget( Builder( builder: (BuildContext context1) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1'), context: context1), }, child: Builder( builder: (BuildContext context2) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationContextAction(actionName: 'action2', enabled: false), context: context2), }, child: Builder( builder: (BuildContext context3) { return Actions( actions: <Type, Action<Intent>> { LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3'), context: context3), }, child: Builder( builder: (BuildContext context4) { invokingContext = context4; return const SizedBox(); }, ), ); }, ), ); }, ), ); }, ), ); Actions.invoke(invokingContext!, LogIntent(log: invocations)); expect(invocations, <String>[ 'action1.invokeAsOverride-pre-super', 'action2.invokeAsOverride-pre-super', 'action3.invoke', 'action2.invokeAsOverride-post-super', 'action1.invokeAsOverride-post-super', ]); // Action2 is a ContextAction and action1 & action2 are regular actions. // Invoking action2 from action3 should still supply a non-null // BuildContext. expect(LogInvocationContextAction.invokeContext, isNotNull); expect(LogInvocationContextAction.invokeContext, invokingContext); }); }); } class TestContextAction extends ContextAction<TestIntent> { List<BuildContext?> capturedContexts = <BuildContext?>[]; @override Object? invoke(covariant TestIntent intent, [BuildContext? context]) { capturedContexts.add(context); return null; } } class LogIntent extends Intent { const LogIntent({ required this.log }); final List<String> log; } class LogInvocationAction extends Action<LogIntent> { LogInvocationAction({ required this.actionName, this.enabled = true }); final String actionName; final bool enabled; @override bool get isActionEnabled => enabled; @override Object? invoke(LogIntent intent) { final Action<LogIntent>? callingAction = this.callingAction; if (callingAction == null) { intent.log.add('$actionName.invoke'); } else { intent.log.add('$actionName.invokeAsOverride-pre-super'); callingAction.invoke(intent); intent.log.add('$actionName.invokeAsOverride-post-super'); } } @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(StringProperty('actionName', actionName)); } } class LogInvocationContextAction extends ContextAction<LogIntent> { LogInvocationContextAction({ required this.actionName, this.enabled = true }); static BuildContext? invokeContext; final String actionName; final bool enabled; @override bool get isActionEnabled => enabled; @override Object? invoke(LogIntent intent, [BuildContext? context]) { invokeContext = context; final Action<LogIntent>? callingAction = this.callingAction; if (callingAction == null) { intent.log.add('$actionName.invoke'); } else { intent.log.add('$actionName.invokeAsOverride-pre-super'); callingAction.invoke(intent); intent.log.add('$actionName.invokeAsOverride-post-super'); } } @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(StringProperty('actionName', actionName)); } } class LogInvocationButDeferIsEnabledAction extends LogInvocationAction { LogInvocationButDeferIsEnabledAction({ required String actionName }) : super(actionName: actionName); // Defer `isActionEnabled` to the overridable action. @override bool get isActionEnabled => callingAction?.isActionEnabled ?? false; } class RedirectOutputAction extends LogInvocationAction { RedirectOutputAction({ required String actionName, bool enabled = true, required this.newLog, }) : super(actionName: actionName, enabled: enabled); final List<String> newLog; @override Object? invoke(LogIntent intent) => super.invoke(LogIntent(log: newLog)); }