Edgar Jan 79caa8373c
Fix and Test Conditional Validator Behavior in FormField (#132714)
In the FormField widget, if a validator is initially set (and validation fails), then subsequently the validator is set to null, the form incorrectly retains its error state. This is not expected behavior as removing the validator should clear any validation errors.
2023-09-25 19:17:07 +00:00

1016 lines
34 KiB
Dart

// 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/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
void main() {
testWidgetsWithLeakTracking('onSaved callback is called', (WidgetTester tester) async {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
String? fieldValue;
Widget builder() {
return MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
key: formKey,
child: TextFormField(
onSaved: (String? value) { fieldValue = value; },
),
),
),
),
),
),
);
}
await tester.pumpWidget(builder());
expect(fieldValue, isNull);
Future<void> checkText(String testValue) async {
await tester.enterText(find.byType(TextFormField), testValue);
formKey.currentState!.save();
// Pumping is unnecessary because callback happens regardless of frames.
expect(fieldValue, equals(testValue));
}
await checkText('Test');
await checkText('');
});
testWidgetsWithLeakTracking('onChanged callback is called', (WidgetTester tester) async {
String? fieldValue;
Widget builder() {
return MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
child: TextField(
onChanged: (String value) { fieldValue = value; },
),
),
),
),
),
),
);
}
await tester.pumpWidget(builder());
expect(fieldValue, isNull);
Future<void> checkText(String testValue) async {
await tester.enterText(find.byType(TextField), testValue);
// pump'ing is unnecessary because callback happens regardless of frames
expect(fieldValue, equals(testValue));
}
await checkText('Test');
await checkText('');
});
testWidgetsWithLeakTracking('Validator sets the error text only when validate is called', (WidgetTester tester) async {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
String? errorText(String? value) => '${value ?? ''}/error';
Widget builder(AutovalidateMode autovalidateMode) {
return MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
key: formKey,
autovalidateMode: autovalidateMode,
child: TextFormField(
validator: errorText,
),
),
),
),
),
),
);
}
// Start off not autovalidating.
await tester.pumpWidget(builder(AutovalidateMode.disabled));
Future<void> checkErrorText(String testValue) async {
formKey.currentState!.reset();
await tester.pumpWidget(builder(AutovalidateMode.disabled));
await tester.enterText(find.byType(TextFormField), testValue);
await tester.pump();
// We have to manually validate if we're not autovalidating.
expect(find.text(errorText(testValue)!), findsNothing);
formKey.currentState!.validate();
await tester.pump();
expect(find.text(errorText(testValue)!), findsOneWidget);
// Try again with autovalidation. Should validate immediately.
formKey.currentState!.reset();
await tester.pumpWidget(builder(AutovalidateMode.always));
await tester.enterText(find.byType(TextFormField), testValue);
await tester.pump();
expect(find.text(errorText(testValue)!), findsOneWidget);
}
await checkErrorText('Test');
await checkErrorText('');
});
testWidgetsWithLeakTracking('Should announce error text when validate returns error', (WidgetTester tester) async {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
key: formKey,
child: TextFormField(
validator: (_)=> 'error',
),
),
),
),
),
),
),
);
formKey.currentState!.reset();
await tester.enterText(find.byType(TextFormField), '');
await tester.pump();
// Manually validate.
expect(find.text('error'), findsNothing);
formKey.currentState!.validate();
await tester.pump();
expect(find.text('error'), findsOneWidget);
final CapturedAccessibilityAnnouncement announcement = tester.takeAnnouncements().single;
expect(announcement.message, 'error');
expect(announcement.textDirection, TextDirection.ltr);
expect(announcement.assertiveness, Assertiveness.assertive);
});
testWidgetsWithLeakTracking('isValid returns true when a field is valid', (WidgetTester tester) async {
final GlobalKey<FormFieldState<String>> fieldKey1 = GlobalKey<FormFieldState<String>>();
final GlobalKey<FormFieldState<String>> fieldKey2 = GlobalKey<FormFieldState<String>>();
const String validString = 'Valid string';
String? validator(String? s) => s == validString ? null : 'Error text';
Widget builder() {
return MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
child: ListView(
children: <Widget>[
TextFormField(
key: fieldKey1,
initialValue: validString,
validator: validator,
autovalidateMode: AutovalidateMode.always,
),
TextFormField(
key: fieldKey2,
initialValue: validString,
validator: validator,
autovalidateMode: AutovalidateMode.always,
),
],
),
),
),
),
),
),
);
}
await tester.pumpWidget(builder());
expect(fieldKey1.currentState!.isValid, isTrue);
expect(fieldKey2.currentState!.isValid, isTrue);
});
testWidgetsWithLeakTracking(
'isValid returns false when the field is invalid and does not change error display',
(WidgetTester tester) async {
final GlobalKey<FormFieldState<String>> fieldKey1 = GlobalKey<FormFieldState<String>>();
final GlobalKey<FormFieldState<String>> fieldKey2 = GlobalKey<FormFieldState<String>>();
const String validString = 'Valid string';
String? validator(String? s) => s == validString ? null : 'Error text';
Widget builder() {
return MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
child: ListView(
children: <Widget>[
TextFormField(
key: fieldKey1,
initialValue: validString,
validator: validator,
autovalidateMode: AutovalidateMode.disabled,
),
TextFormField(
key: fieldKey2,
initialValue: '',
validator: validator,
autovalidateMode: AutovalidateMode.disabled,
),
],
),
),
),
),
),
),
);
}
await tester.pumpWidget(builder());
expect(fieldKey1.currentState!.isValid, isTrue);
expect(fieldKey2.currentState!.isValid, isFalse);
expect(fieldKey2.currentState!.hasError, isFalse);
},
);
testWidgetsWithLeakTracking('Multiple TextFormFields communicate', (WidgetTester tester) async {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
final GlobalKey<FormFieldState<String>> fieldKey = GlobalKey<FormFieldState<String>>();
// Input 2's validator depends on a input 1's value.
String? errorText(String? input) => '${fieldKey.currentState!.value}/error';
Widget builder() {
return MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
key: formKey,
autovalidateMode: AutovalidateMode.always,
child: ListView(
children: <Widget>[
TextFormField(
key: fieldKey,
),
TextFormField(
validator: errorText,
),
],
),
),
),
),
),
),
);
}
await tester.pumpWidget(builder());
Future<void> checkErrorText(String testValue) async {
await tester.enterText(find.byType(TextFormField).first, testValue);
await tester.pump();
// Check for a new Text widget with our error text.
expect(find.text('$testValue/error'), findsOneWidget);
return;
}
await checkErrorText('Test');
await checkErrorText('');
});
testWidgetsWithLeakTracking('Provide initial value to input when no controller is specified', (WidgetTester tester) async {
const String initialValue = 'hello';
final GlobalKey<FormFieldState<String>> inputKey = GlobalKey<FormFieldState<String>>();
Widget builder() {
return MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
child: TextFormField(
key: inputKey,
initialValue: 'hello',
),
),
),
),
),
),
);
}
await tester.pumpWidget(builder());
await tester.showKeyboard(find.byType(TextFormField));
// initial value should be loaded into keyboard editing state
expect(tester.testTextInput.editingState, isNotNull);
expect(tester.testTextInput.editingState!['text'], equals(initialValue));
// initial value should also be visible in the raw input line
final EditableTextState editableText = tester.state(find.byType(EditableText));
expect(editableText.widget.controller.text, equals(initialValue));
// sanity check, make sure we can still edit the text and everything updates
expect(inputKey.currentState!.value, equals(initialValue));
await tester.enterText(find.byType(TextFormField), 'world');
await tester.pump();
expect(inputKey.currentState!.value, equals('world'));
expect(editableText.widget.controller.text, equals('world'));
});
testWidgetsWithLeakTracking('Controller defines initial value', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(text: 'hello');
addTearDown(controller.dispose);
const String initialValue = 'hello';
final GlobalKey<FormFieldState<String>> inputKey = GlobalKey<FormFieldState<String>>();
Widget builder() {
return MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
child: TextFormField(
key: inputKey,
controller: controller,
),
),
),
),
),
),
);
}
await tester.pumpWidget(builder());
await tester.showKeyboard(find.byType(TextFormField));
// initial value should be loaded into keyboard editing state
expect(tester.testTextInput.editingState, isNotNull);
expect(tester.testTextInput.editingState!['text'], equals(initialValue));
// initial value should also be visible in the raw input line
final EditableTextState editableText = tester.state(find.byType(EditableText));
expect(editableText.widget.controller.text, equals(initialValue));
expect(controller.text, equals(initialValue));
// sanity check, make sure we can still edit the text and everything updates
expect(inputKey.currentState!.value, equals(initialValue));
await tester.enterText(find.byType(TextFormField), 'world');
await tester.pump();
expect(inputKey.currentState!.value, equals('world'));
expect(editableText.widget.controller.text, equals('world'));
expect(controller.text, equals('world'));
});
testWidgetsWithLeakTracking('TextFormField resets to its initial value', (WidgetTester tester) async {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
final GlobalKey<FormFieldState<String>> inputKey = GlobalKey<FormFieldState<String>>();
final TextEditingController controller = TextEditingController(text: 'Plover');
addTearDown(controller.dispose);
Widget builder() {
return MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
key: formKey,
child: TextFormField(
key: inputKey,
controller: controller,
// initialValue is 'Plover'
),
),
),
),
),
),
);
}
await tester.pumpWidget(builder());
await tester.showKeyboard(find.byType(TextFormField));
final EditableTextState editableText = tester.state(find.byType(EditableText));
// overwrite initial value.
controller.text = 'Xyzzy';
await tester.idle();
expect(editableText.widget.controller.text, equals('Xyzzy'));
expect(inputKey.currentState!.value, equals('Xyzzy'));
expect(controller.text, equals('Xyzzy'));
// verify value resets to initialValue on reset.
formKey.currentState!.reset();
await tester.idle();
expect(inputKey.currentState!.value, equals('Plover'));
expect(editableText.widget.controller.text, equals('Plover'));
expect(controller.text, equals('Plover'));
});
testWidgetsWithLeakTracking('TextEditingController updates to/from form field value', (WidgetTester tester) async {
final TextEditingController controller1 = TextEditingController(text: 'Foo');
addTearDown(controller1.dispose);
final TextEditingController controller2 = TextEditingController(text: 'Bar');
addTearDown(controller2.dispose);
final GlobalKey<FormFieldState<String>> inputKey = GlobalKey<FormFieldState<String>>();
TextEditingController? currentController;
late StateSetter setState;
Widget builder() {
return StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
child: TextFormField(
key: inputKey,
controller: currentController,
),
),
),
),
),
),
);
},
);
}
await tester.pumpWidget(builder());
await tester.showKeyboard(find.byType(TextFormField));
// verify initially empty.
expect(tester.testTextInput.editingState, isNotNull);
expect(tester.testTextInput.editingState!['text'], isEmpty);
final EditableTextState editableText = tester.state(find.byType(EditableText));
expect(editableText.widget.controller.text, isEmpty);
// verify changing the controller from null to controller1 sets the value.
setState(() {
currentController = controller1;
});
await tester.pump();
expect(editableText.widget.controller.text, equals('Foo'));
expect(inputKey.currentState!.value, equals('Foo'));
// verify changes to controller1 text are visible in text field and set in form value.
controller1.text = 'Wobble';
await tester.idle();
expect(editableText.widget.controller.text, equals('Wobble'));
expect(inputKey.currentState!.value, equals('Wobble'));
// verify changes to the field text update the form value and controller1.
await tester.enterText(find.byType(TextFormField), 'Wibble');
await tester.pump();
expect(inputKey.currentState!.value, equals('Wibble'));
expect(editableText.widget.controller.text, equals('Wibble'));
expect(controller1.text, equals('Wibble'));
// verify that switching from controller1 to controller2 is handled.
setState(() {
currentController = controller2;
});
await tester.pump();
expect(inputKey.currentState!.value, equals('Bar'));
expect(editableText.widget.controller.text, equals('Bar'));
expect(controller2.text, equals('Bar'));
expect(controller1.text, equals('Wibble'));
// verify changes to controller2 text are visible in text field and set in form value.
controller2.text = 'Xyzzy';
await tester.idle();
expect(editableText.widget.controller.text, equals('Xyzzy'));
expect(inputKey.currentState!.value, equals('Xyzzy'));
expect(controller1.text, equals('Wibble'));
// verify changes to controller1 text are not visible in text field or set in form value.
controller1.text = 'Plugh';
await tester.idle();
expect(editableText.widget.controller.text, equals('Xyzzy'));
expect(inputKey.currentState!.value, equals('Xyzzy'));
expect(controller1.text, equals('Plugh'));
// verify that switching from controller2 to null is handled.
setState(() {
currentController = null;
});
await tester.pump();
expect(inputKey.currentState!.value, equals('Xyzzy'));
expect(editableText.widget.controller.text, equals('Xyzzy'));
expect(controller2.text, equals('Xyzzy'));
expect(controller1.text, equals('Plugh'));
// verify that changes to the field text update the form value but not the previous controllers.
await tester.enterText(find.byType(TextFormField), 'Plover');
await tester.pump();
expect(inputKey.currentState!.value, equals('Plover'));
expect(editableText.widget.controller.text, equals('Plover'));
expect(controller1.text, equals('Plugh'));
expect(controller2.text, equals('Xyzzy'));
});
testWidgetsWithLeakTracking('No crash when a TextFormField is removed from the tree', (WidgetTester tester) async {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
String? fieldValue;
Widget builder(bool remove) {
return MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
key: formKey,
child: remove ? Container() : TextFormField(
autofocus: true,
onSaved: (String? value) { fieldValue = value; },
validator: (String? value) { return (value == null || value.isEmpty) ? null : 'yes'; },
),
),
),
),
),
),
);
}
await tester.pumpWidget(builder(false));
expect(fieldValue, isNull);
expect(formKey.currentState!.validate(), isTrue);
await tester.enterText(find.byType(TextFormField), 'Test');
await tester.pumpWidget(builder(false));
// Form wasn't saved yet.
expect(fieldValue, null);
expect(formKey.currentState!.validate(), isFalse);
formKey.currentState!.save();
// Now fieldValue is saved.
expect(fieldValue, 'Test');
expect(formKey.currentState!.validate(), isFalse);
// Now remove the field with an error.
await tester.pumpWidget(builder(true));
// Reset the form. Should not crash.
formKey.currentState!.reset();
formKey.currentState!.save();
expect(formKey.currentState!.validate(), isTrue);
});
testWidgetsWithLeakTracking('Does not auto-validate before value changes when autovalidateMode is set to onUserInteraction', (WidgetTester tester) async {
late FormFieldState<String> formFieldState;
String? errorText(String? value) => '$value/error';
Widget builder() {
return MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: FormField<String>(
initialValue: 'foo',
autovalidateMode: AutovalidateMode.onUserInteraction,
builder: (FormFieldState<String> state) {
formFieldState = state;
return Container();
},
validator: errorText,
),
),
),
),
),
);
}
await tester.pumpWidget(builder());
// The form field has no error.
expect(formFieldState.hasError, isFalse);
// No error widget is visible.
expect(find.text(errorText('foo')!), findsNothing);
});
testWidgetsWithLeakTracking('auto-validate before value changes if autovalidateMode was set to always', (WidgetTester tester) async {
late FormFieldState<String> formFieldState;
String? errorText(String? value) => '$value/error';
Widget builder() {
return MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: FormField<String>(
initialValue: 'foo',
autovalidateMode: AutovalidateMode.always,
builder: (FormFieldState<String> state) {
formFieldState = state;
return Container();
},
validator: errorText,
),
),
),
),
),
);
}
await tester.pumpWidget(builder());
expect(formFieldState.hasError, isTrue);
});
testWidgetsWithLeakTracking('Form auto-validates form fields only after one of them changes if autovalidateMode is onUserInteraction', (WidgetTester tester) async {
const String initialValue = 'foo';
String? errorText(String? value) => 'error/$value';
Widget builder() {
return MaterialApp(
home: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
children: <Widget>[
TextFormField(
initialValue: initialValue,
validator: errorText,
),
TextFormField(
initialValue: initialValue,
validator: errorText,
),
TextFormField(
initialValue: initialValue,
validator: errorText,
),
],
),
),
),
),
),
);
}
// Makes sure the Form widget won't auto-validate the form fields
// after rebuilds if there is not user interaction.
await tester.pumpWidget(builder());
await tester.pumpWidget(builder());
// We expect no validation error text being shown.
expect(find.text(errorText(initialValue)!), findsNothing);
// Set a empty string into the first form field to
// trigger the fields validators.
await tester.enterText(find.byType(TextFormField).first, '');
await tester.pump();
// Now we expect the errors to be shown for the first Text Field and
// for the next two form fields that have their contents unchanged.
expect(find.text(errorText('')!), findsOneWidget);
expect(find.text(errorText(initialValue)!), findsNWidgets(2));
});
testWidgetsWithLeakTracking('Form auto-validates form fields even before any have changed if autovalidateMode is set to always', (WidgetTester tester) async {
String? errorText(String? value) => 'error/$value';
Widget builder() {
return MaterialApp(
home: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
autovalidateMode: AutovalidateMode.always,
child: TextFormField(
validator: errorText,
),
),
),
),
),
);
}
// The issue only happens on the second build so we
// need to rebuild the tree twice.
await tester.pumpWidget(builder());
await tester.pumpWidget(builder());
// We expect validation error text being shown.
expect(find.text(errorText('')!), findsOneWidget);
});
testWidgetsWithLeakTracking('Form.reset() resets form fields, and auto validation will only happen on the next user interaction if autovalidateMode is onUserInteraction', (WidgetTester tester) async {
final GlobalKey<FormState> formState = GlobalKey<FormState>();
String? errorText(String? value) => '$value/error';
Widget builder() {
return MaterialApp(
theme: ThemeData(),
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Form(
key: formState,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Material(
child: TextFormField(
initialValue: 'foo',
validator: errorText,
),
),
),
),
),
),
);
}
await tester.pumpWidget(builder());
// No error text is visible yet.
expect(find.text(errorText('foo')!), findsNothing);
await tester.enterText(find.byType(TextFormField), 'bar');
await tester.pumpAndSettle();
await tester.pump();
expect(find.text(errorText('bar')!), findsOneWidget);
// Resetting the form state should remove the error text.
formState.currentState!.reset();
await tester.pump();
expect(find.text(errorText('bar')!), findsNothing);
});
// Regression test for https://github.com/flutter/flutter/issues/63753.
testWidgetsWithLeakTracking('Validate form should return correct validation if the value is composing', (WidgetTester tester) async {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
String? fieldValue;
final Widget widget = MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
key: formKey,
child: TextFormField(
maxLength: 5,
maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds,
onSaved: (String? value) { fieldValue = value; },
validator: (String? value) => (value != null && value.length > 5) ? 'Exceeded' : null,
),
),
),
),
),
),
);
await tester.pumpWidget(widget);
final EditableTextState editableText = tester.state<EditableTextState>(find.byType(EditableText));
editableText.updateEditingValue(const TextEditingValue(text: '123456', composing: TextRange(start: 2, end: 5)));
expect(editableText.currentTextEditingValue.composing, const TextRange(start: 2, end: 5));
formKey.currentState!.save();
expect(fieldValue, '123456');
expect(formKey.currentState!.validate(), isFalse);
});
testWidgetsWithLeakTracking('hasInteractedByUser returns false when the input has not changed', (WidgetTester tester) async {
final GlobalKey<FormFieldState<String>> fieldKey = GlobalKey<FormFieldState<String>>();
final Widget widget = MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: TextFormField(
key: fieldKey,
),
),
),
),
),
);
await tester.pumpWidget(widget);
expect(fieldKey.currentState!.hasInteractedByUser, isFalse);
});
testWidgetsWithLeakTracking('hasInteractedByUser returns true after the input has changed', (WidgetTester tester) async {
final GlobalKey<FormFieldState<String>> fieldKey = GlobalKey<FormFieldState<String>>();
final Widget widget = MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: TextFormField(
key: fieldKey,
),
),
),
),
),
);
await tester.pumpWidget(widget);
// initially, the field has not been interacted with
expect(fieldKey.currentState!.hasInteractedByUser, isFalse);
// after entering text, the field has been interacted with
await tester.enterText(find.byType(TextFormField), 'foo');
expect(fieldKey.currentState!.hasInteractedByUser, isTrue);
});
testWidgetsWithLeakTracking('hasInteractedByUser returns false after the field is reset', (WidgetTester tester) async {
final GlobalKey<FormFieldState<String>> fieldKey = GlobalKey<FormFieldState<String>>();
final Widget widget = MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: TextFormField(
key: fieldKey,
),
),
),
),
),
);
await tester.pumpWidget(widget);
// initially, the field has not been interacted with
expect(fieldKey.currentState!.hasInteractedByUser, isFalse);
// after entering text, the field has been interacted with
await tester.enterText(find.byType(TextFormField), 'foo');
expect(fieldKey.currentState!.hasInteractedByUser, isTrue);
// after resetting the field, it has not been interacted with again
fieldKey.currentState!.reset();
expect(fieldKey.currentState!.hasInteractedByUser, isFalse);
});
testWidgets('Validator is nullified and error text behaves accordingly',
(WidgetTester tester) async {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
bool useValidator = false;
late StateSetter setState;
String? validator(String? value) {
if (value == null || value.isEmpty) {
return 'test_error';
}
return null;
}
Widget builder() {
return StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: Form(
key: formKey,
child: TextFormField(
validator: useValidator ? validator : null,
),
),
),
),
),
),
);
},
);
}
await tester.pumpWidget(builder());
// Start with no validator.
await tester.enterText(find.byType(TextFormField), '');
await tester.pump();
formKey.currentState!.validate();
await tester.pump();
expect(find.text('test_error'), findsNothing);
// Now use the validator.
setState(() {
useValidator = true;
});
await tester.pump();
formKey.currentState!.validate();
await tester.pump();
expect(find.text('test_error'), findsOneWidget);
// Remove the validator again and expect the error to disappear.
setState(() {
useValidator = false;
});
await tester.pump();
formKey.currentState!.validate();
await tester.pump();
expect(find.text('test_error'), findsNothing);
});
}