Unverified Commit 285b4751 authored by Ian Hickson's avatar Ian Hickson Committed by GitHub

Refactor text editing test APIs (Mark III) (#80003)

parent 4f3ec01d
...@@ -20,9 +20,6 @@ void main() { ...@@ -20,9 +20,6 @@ void main() {
app.main(); app.main();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// TODO(nurhan): https://github.com/flutter/flutter/issues/51885
SystemChannels.textInput.setMockMethodCallHandler(null);
// Focus on a TextFormField. // Focus on a TextFormField.
final Finder finder = find.byKey(const Key('input')); final Finder finder = find.byKey(const Key('input'));
expect(finder, findsOneWidget); expect(finder, findsOneWidget);
...@@ -48,9 +45,6 @@ void main() { ...@@ -48,9 +45,6 @@ void main() {
app.main(); app.main();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// TODO(nurhan): https://github.com/flutter/flutter/issues/51885
SystemChannels.textInput.setMockMethodCallHandler(null);
// Focus on a TextFormField. // Focus on a TextFormField.
final Finder finder = find.byKey(const Key('empty-input')); final Finder finder = find.byKey(const Key('empty-input'));
expect(finder, findsOneWidget); expect(finder, findsOneWidget);
...@@ -76,9 +70,6 @@ void main() { ...@@ -76,9 +70,6 @@ void main() {
app.main(); app.main();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// TODO(nurhan): https://github.com/flutter/flutter/issues/51885
SystemChannels.textInput.setMockMethodCallHandler(null);
// This text will show no-enter initially. It will have 'enter-pressed' // This text will show no-enter initially. It will have 'enter-pressed'
// after `onFieldSubmitted` of TextField is triggered. // after `onFieldSubmitted` of TextField is triggered.
final Finder textFinder = find.byKey(const Key('text')); final Finder textFinder = find.byKey(const Key('text'));
...@@ -112,9 +103,6 @@ void main() { ...@@ -112,9 +103,6 @@ void main() {
app.main(); app.main();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// TODO(nurhan): https://github.com/flutter/flutter/issues/51885
SystemChannels.textInput.setMockMethodCallHandler(null);
// Focus on a TextFormField. // Focus on a TextFormField.
final Finder finder = find.byKey(const Key('input')); final Finder finder = find.byKey(const Key('input'));
expect(finder, findsOneWidget); expect(finder, findsOneWidget);
...@@ -147,9 +135,6 @@ void main() { ...@@ -147,9 +135,6 @@ void main() {
app.main(); app.main();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// TODO(nurhan): https://github.com/flutter/flutter/issues/51885
SystemChannels.textInput.setMockMethodCallHandler(null);
// Focus on a TextFormField. // Focus on a TextFormField.
final Finder finder = find.byKey(const Key('input')); final Finder finder = find.byKey(const Key('input'));
expect(finder, findsOneWidget); expect(finder, findsOneWidget);
...@@ -197,9 +182,6 @@ void main() { ...@@ -197,9 +182,6 @@ void main() {
app.main(); app.main();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// TODO(nurhan): https://github.com/flutter/flutter/issues/51885
SystemChannels.textInput.setMockMethodCallHandler(null);
// Select something from the selectable text. // Select something from the selectable text.
final Finder finder = find.byKey(const Key('selectable')); final Finder finder = find.byKey(const Key('selectable'));
expect(finder, findsOneWidget); expect(finder, findsOneWidget);
......
...@@ -120,6 +120,11 @@ class SystemChannels { ...@@ -120,6 +120,11 @@ class SystemChannels {
/// they apply, so that stale messages referencing past transactions can be /// they apply, so that stale messages referencing past transactions can be
/// ignored. /// ignored.
/// ///
/// In debug builds, messages sent with a client ID of -1 are always accepted.
/// This allows tests to smuggle messages without having to mock the engine's
/// text handling (for example, allowing the engine to still handle the text
/// input messages in an integration test).
///
/// The methods described below are wrapped in a more convenient form by the /// The methods described below are wrapped in a more convenient form by the
/// [TextInput] and [TextInputConnection] class. /// [TextInput] and [TextInputConnection] class.
/// ///
...@@ -152,9 +157,15 @@ class SystemChannels { ...@@ -152,9 +157,15 @@ class SystemChannels {
/// is a transaction identifier. Calls for stale transactions should be ignored. /// is a transaction identifier. Calls for stale transactions should be ignored.
/// ///
/// * `TextInputClient.updateEditingState`: The user has changed the contents /// * `TextInputClient.updateEditingState`: The user has changed the contents
/// of the text control. The second argument is a [String] containing a /// of the text control. The second argument is an object with seven keys,
/// JSON-encoded object with seven keys, in the form expected by /// in the form expected by [TextEditingValue.fromJSON].
/// [TextEditingValue.fromJSON]. ///
/// * `TextInputClient.updateEditingStateWithTag`: One or more text controls
/// were autofilled by the platform's autofill service. The first argument
/// (the client ID) is ignored, the second argument is a map of tags to
/// objects in the form expected by [TextEditingValue.fromJSON]. See
/// [AutofillScope.getAutofillClient] for details on the interpretation of
/// the tag.
/// ///
/// * `TextInputClient.performAction`: The user has triggered an action. The /// * `TextInputClient.performAction`: The user has triggered an action. The
/// second argument is a [String] consisting of the stringification of one /// second argument is a [String] consisting of the stringification of one
...@@ -165,7 +176,8 @@ class SystemChannels { ...@@ -165,7 +176,8 @@ class SystemChannels {
/// one. The framework should call `TextInput.setClient` and /// one. The framework should call `TextInput.setClient` and
/// `TextInput.setEditingState` again with its most recent information. If /// `TextInput.setEditingState` again with its most recent information. If
/// there is no existing state on the framework side, the call should /// there is no existing state on the framework side, the call should
/// fizzle. /// fizzle. (This call is made without a client ID; indeed, without any
/// arguments at all.)
/// ///
/// * `TextInputClient.onConnectionClosed`: The text input connection closed /// * `TextInputClient.onConnectionClosed`: The text input connection closed
/// on the platform side. For example the application is moved to /// on the platform side. For example the application is moved to
......
...@@ -1327,9 +1327,11 @@ class TextInput { ...@@ -1327,9 +1327,11 @@ class TextInput {
final List<dynamic> args = methodCall.arguments as List<dynamic>; final List<dynamic> args = methodCall.arguments as List<dynamic>;
// The updateEditingStateWithTag request (autofill) can come up even to a
// text field that doesn't have a connection.
if (method == 'TextInputClient.updateEditingStateWithTag') { if (method == 'TextInputClient.updateEditingStateWithTag') {
assert(_currentConnection!._client != null);
final TextInputClient client = _currentConnection!._client; final TextInputClient client = _currentConnection!._client;
assert(client != null);
final AutofillScope? scope = client.currentAutofillScope; final AutofillScope? scope = client.currentAutofillScope;
final Map<String, dynamic> editingValue = args[1] as Map<String, dynamic>; final Map<String, dynamic> editingValue = args[1] as Map<String, dynamic>;
for (final String tag in editingValue.keys) { for (final String tag in editingValue.keys) {
...@@ -1343,9 +1345,22 @@ class TextInput { ...@@ -1343,9 +1345,22 @@ class TextInput {
} }
final int client = args[0] as int; final int client = args[0] as int;
// The incoming message was for a different client. if (client != _currentConnection!._id) {
if (client != _currentConnection!._id) // If the client IDs don't match, the incoming message was for a different
// client.
bool debugAllowAnyway = false;
assert(() {
// In debug builds we allow "-1" as a magical client ID that ignores
// this verification step so that tests can always get through, even
// when they are not mocking the engine side of text input.
if (client == -1)
debugAllowAnyway = true;
return true;
}());
if (!debugAllowAnyway)
return; return;
}
switch (method) { switch (method) {
case 'TextInputClient.updateEditingState': case 'TextInputClient.updateEditingState':
_currentConnection!._client.updateEditingValue(TextEditingValue.fromJSON(args[1] as Map<String, dynamic>)); _currentConnection!._client.updateEditingValue(TextEditingValue.fromJSON(args[1] as Map<String, dynamic>));
......
...@@ -2112,7 +2112,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2112,7 +2112,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
if (_hasFocus) { if (_hasFocus) {
_openInputConnection(); _openInputConnection();
} else { } else {
widget.focusNode.requestFocus(); widget.focusNode.requestFocus(); // This eventually calls _openInputConnection also, see _handleFocusChanged.
} }
} }
......
...@@ -195,9 +195,15 @@ abstract class TestWidgetsFlutterBinding extends BindingBase ...@@ -195,9 +195,15 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
/// Called by the test framework at the beginning of a widget test to /// Called by the test framework at the beginning of a widget test to
/// prepare the binding for the next test. /// prepare the binding for the next test.
///
/// If [registerTestTextInput] returns true when this method is called,
/// the [testTextInput] is configured to simulate the keyboard.
void reset() { void reset() {
_restorationManager = null; _restorationManager = null;
resetGestureBinding(); resetGestureBinding();
testTextInput.reset();
if (registerTestTextInput)
_testTextInput.register();
} }
@override @override
...@@ -237,7 +243,8 @@ abstract class TestWidgetsFlutterBinding extends BindingBase ...@@ -237,7 +243,8 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
@protected @protected
bool get overrideHttpClient => true; bool get overrideHttpClient => true;
/// Determines whether the binding automatically registers [testTextInput]. /// Determines whether the binding automatically registers [testTextInput] as
/// a fake keyboard implementation.
/// ///
/// Unit tests make use of this to mock out text input communication for /// Unit tests make use of this to mock out text input communication for
/// widgets. An integration test would set this to false, to test real IME /// widgets. An integration test would set this to false, to test real IME
...@@ -245,6 +252,19 @@ abstract class TestWidgetsFlutterBinding extends BindingBase ...@@ -245,6 +252,19 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
/// ///
/// [TestTextInput.isRegistered] reports whether the text input mock is /// [TestTextInput.isRegistered] reports whether the text input mock is
/// registered or not. /// registered or not.
///
/// Some of the properties and methods on [testTextInput] are only valid if
/// [registerTestTextInput] returns true when a test starts. If those
/// members are accessed when using a binding that sets this flag to false,
/// they will throw.
///
/// If this property returns true when a test ends, the [testTextInput] is
/// unregistered.
///
/// This property should not change the value it returns during the lifetime
/// of the binding. Changing the value of this property risks very confusing
/// behavior as the [TestTextInput] may be inconsistently registered or
/// unregistered.
@protected @protected
bool get registerTestTextInput => true; bool get registerTestTextInput => true;
...@@ -319,9 +339,6 @@ abstract class TestWidgetsFlutterBinding extends BindingBase ...@@ -319,9 +339,6 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
binding.setupHttpOverrides(); binding.setupHttpOverrides();
} }
_testTextInput = TestTextInput(onCleared: _resetFocusedEditable); _testTextInput = TestTextInput(onCleared: _resetFocusedEditable);
if (registerTestTextInput) {
_testTextInput.register();
}
} }
@override @override
...@@ -515,12 +532,20 @@ abstract class TestWidgetsFlutterBinding extends BindingBase ...@@ -515,12 +532,20 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
TestTextInput get testTextInput => _testTextInput; TestTextInput get testTextInput => _testTextInput;
late TestTextInput _testTextInput; late TestTextInput _testTextInput;
/// The current client of the onscreen keyboard. Callers must pump /// The [State] of the current [EditableText] client of the onscreen keyboard.
/// an additional frame after setting this property to complete the ///
/// focus change. /// Setting this property to a new value causes the given [EditableTextState]
/// to focus itself and request the keyboard to establish a
/// [TextInputConnection].
///
/// Callers must pump an additional frame after setting this property to
/// complete the focus change.
/// ///
/// Instead of setting this directly, consider using /// Instead of setting this directly, consider using
/// [WidgetTester.showKeyboard]. /// [WidgetTester.showKeyboard].
//
// TODO(ianh): We should just remove this property and move the call to
// requestKeyboard to the WidgetTester.showKeyboard method.
EditableTextState? get focusedEditable => _focusedEditable; EditableTextState? get focusedEditable => _focusedEditable;
EditableTextState? _focusedEditable; EditableTextState? _focusedEditable;
set focusedEditable(EditableTextState? value) { set focusedEditable(EditableTextState? value) {
...@@ -799,6 +824,8 @@ abstract class TestWidgetsFlutterBinding extends BindingBase ...@@ -799,6 +824,8 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
// alone so that we don't cause more spurious errors. // alone so that we don't cause more spurious errors.
runApp(Container(key: UniqueKey(), child: _postTestMessage)); // Unmount any remaining widgets. runApp(Container(key: UniqueKey(), child: _postTestMessage)); // Unmount any remaining widgets.
await pump(); await pump();
if (registerTestTextInput)
_testTextInput.unregister();
invariantTester(); invariantTester();
_verifyAutoUpdateGoldensUnset(autoUpdateGoldensBeforeTest && !isBrowser); _verifyAutoUpdateGoldensUnset(autoUpdateGoldensBeforeTest && !isBrowser);
_verifyReportTestExceptionUnset(reportTestExceptionBeforeTest); _verifyReportTestExceptionUnset(reportTestExceptionBeforeTest);
......
...@@ -149,7 +149,6 @@ void testWidgets( ...@@ -149,7 +149,6 @@ void testWidgets(
() async { () async {
binding.reset(); binding.reset();
debugResetSemanticsIdCounter(); debugResetSemanticsIdCounter();
tester.resetTestTextInput();
Object? memento; Object? memento;
try { try {
memento = await variant.setUp(value); memento = await variant.setUp(value);
...@@ -1002,18 +1001,13 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker ...@@ -1002,18 +1001,13 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker
/// ///
/// Typical app tests will not need to use this value. To add text to widgets /// Typical app tests will not need to use this value. To add text to widgets
/// like [TextField] or [TextFormField], call [enterText]. /// like [TextField] or [TextFormField], call [enterText].
TestTextInput get testTextInput => binding.testTextInput;
/// Ensures that [testTextInput] is registered and [TestTextInput.log] is
/// reset.
/// ///
/// This is called by the testing framework before test runs, so that if a /// Some of the properties and methods on this value are only valid if the
/// previous test has set its own handler on [SystemChannels.textInput], the /// binding's [TestWidgetsFlutterBinding.registerTestTextInput] flag is set to
/// [testTextInput] regains control and the log is fresh for the new test. /// true as a test is starting (meaning that the keyboard is to be simulated
/// It should not typically need to be called by tests. /// by the test framework). If those members are accessed when using a binding
void resetTestTextInput() { /// that sets this flag to false, they will throw.
testTextInput.resetAndRegister(); TestTextInput get testTextInput => binding.testTextInput;
}
/// Give the text input widget specified by [finder] the focus, as if the /// Give the text input widget specified by [finder] the focus, as if the
/// onscreen keyboard had appeared. /// onscreen keyboard had appeared.
...@@ -1035,6 +1029,9 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker ...@@ -1035,6 +1029,9 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker
matchRoot: true, matchRoot: true,
), ),
); );
// Setting focusedEditable causes the binding to call requestKeyboard()
// on the EditableTextState, which itself eventually calls TextInput.attach
// to establish the connection.
binding.focusedEditable = editable; binding.focusedEditable = editable;
await pump(); await pump();
}); });
...@@ -1052,6 +1049,12 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker ...@@ -1052,6 +1049,12 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker
/// ///
/// To just give [finder] the focus without entering any text, /// To just give [finder] the focus without entering any text,
/// see [showKeyboard]. /// see [showKeyboard].
///
/// To enter text into other widgets (e.g. a custom widget that maintains a
/// TextInputConnection the way that a [EditableText] does), first ensure that
/// that widget has an open connection (e.g. by using [tap] to to focus it),
/// then call `testTextInput.enterText` directly (see
/// [TestTextInput.enterText]).
Future<void> enterText(Finder finder, String text) async { Future<void> enterText(Finder finder, String text) async {
return TestAsyncUtils.guard<void>(() async { return TestAsyncUtils.guard<void>(() async {
await showKeyboard(finder); await showKeyboard(finder);
......
...@@ -11,6 +11,8 @@ import 'package:flutter_test/flutter_test.dart'; ...@@ -11,6 +11,8 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:test_api/test_api.dart' as test_package; import 'package:test_api/test_api.dart' as test_package;
void main() { void main() {
final AutomatedTestWidgetsFlutterBinding binding = AutomatedTestWidgetsFlutterBinding();
group(TestViewConfiguration, () { group(TestViewConfiguration, () {
test('is initialized with top-level window if one is not provided', () { test('is initialized with top-level window if one is not provided', () {
// The code below will throw without the default. // The code below will throw without the default.
...@@ -20,15 +22,32 @@ void main() { ...@@ -20,15 +22,32 @@ void main() {
group(AutomatedTestWidgetsFlutterBinding, () { group(AutomatedTestWidgetsFlutterBinding, () {
test('allows setting defaultTestTimeout to 5 minutes', () { test('allows setting defaultTestTimeout to 5 minutes', () {
final AutomatedTestWidgetsFlutterBinding binding = AutomatedTestWidgetsFlutterBinding();
binding.defaultTestTimeout = const test_package.Timeout(Duration(minutes: 5)); binding.defaultTestTimeout = const test_package.Timeout(Duration(minutes: 5));
expect(binding.defaultTestTimeout.duration, const Duration(minutes: 5)); expect(binding.defaultTestTimeout.duration, const Duration(minutes: 5));
}); });
}); });
// The next three tests must run in order -- first using `test`, then `testWidgets`, then `test` again.
int order = 0;
test('Initializes httpOverrides and testTextInput', () async { test('Initializes httpOverrides and testTextInput', () async {
final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized() as TestWidgetsFlutterBinding; assert(order == 0);
expect(binding.testTextInput.isRegistered, true); expect(binding.testTextInput, isNotNull);
expect(binding.testTextInput.isRegistered, isFalse);
expect(HttpOverrides.current, isNotNull); expect(HttpOverrides.current, isNotNull);
order += 1;
});
testWidgets('Registers testTextInput', (WidgetTester tester) async {
assert(order == 1);
expect(tester.testTextInput.isRegistered, isTrue);
order += 1;
});
test('Unregisters testTextInput', () async {
assert(order == 2);
expect(binding.testTextInput.isRegistered, isFalse);
order += 1;
}); });
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment