// 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. // @dart = 2.8 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/widgets.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 { 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 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, nullOk: true, ); 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.find<DoNothingIntent>(containerKey.currentContext, nullOk: true), isNull); await tester.pumpWidget( Actions( dispatcher: testDispatcher, actions: <Type, Action<Intent>>{ TestIntent: testAction, }, child: Container( 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.find<DoNothingIntent>(containerKey.currentContext, nullOk: true), 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: <LogicalKeySet, Intent>{ LogicalKeySet(LogicalKeyboardKey.enter): intent, }, actions: <Type, Action<Intent>>{ TestIntent: testAction, }, onShowHoverHighlight: (bool value) => hovering = value, onShowFocusHighlight: (bool value) => focusing = value, child: Container(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); }); }); 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.invoke( 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.invoke<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(); bool invoked; bool hovering; bool focusing; FocusNode focusNode; 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: <LogicalKeySet, Intent>{ LogicalKeySet(LogicalKeyboardKey.enter): intent, }, actions: <Type, Action<Intent>>{ TestIntent: testAction, }, onShowHoverHighlight: supplyCallbacks ? (bool value) => hovering = value : null, onShowFocusHighlight: supplyCallbacks ? (bool value) => focusing = value : null, child: Container(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); }); }); 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}')); }); }); }