Skip to content

Commit

Permalink
Added tests for text entry on mobile using an IME simulator in flutte…
Browse files Browse the repository at this point in the history
…r_test_robots (Resolves #620)
  • Loading branch information
matthew-carroll committed Aug 12, 2022
1 parent a81e83c commit 9798e10
Show file tree
Hide file tree
Showing 6 changed files with 312 additions and 21 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:super_editor/src/infrastructure/super_textfield/android/_user_in
import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/hint_text.dart';
import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/text_scrollview.dart';
import 'package:super_editor/src/infrastructure/super_textfield/input_method_engine/_ime_text_editing_controller.dart';
import 'package:super_editor/src/infrastructure/super_textfield/input_method_engine/ime_input_owner.dart';
import 'package:super_text_layout/super_text_layout.dart';

import '../../_logging.dart';
Expand Down Expand Up @@ -128,7 +129,7 @@ class SuperAndroidTextField extends StatefulWidget {

class SuperAndroidTextFieldState extends State<SuperAndroidTextField>
with SingleTickerProviderStateMixin
implements ProseTextBlock {
implements ProseTextBlock, ImeInputOwner {
final _textFieldKey = GlobalKey();
final _textFieldLayerLink = LayerLink();
final _textContentLayerLink = LayerLink();
Expand Down Expand Up @@ -261,6 +262,9 @@ class SuperAndroidTextFieldState extends State<SuperAndroidTextField>
@override
ProseTextLayout get textLayout => _textContentKey.currentState!.textLayout;

@override
DeltaTextInputClient get imeClient => _textEditingController;

bool get _isMultiline => (widget.minLines ?? 1) != 1 || (widget.maxLines ?? 1) != 1;

void _onFocusChange() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import 'package:flutter/services.dart';

/// A widget that internally accepts IME input.
///
/// Tests may wish to simulate IME input, in which case the test needs to obtain a reference to the
/// [DeltaTextInputClient], because Flutter doesn't make it possible to truly simulate platform IME input
/// (https://github.com/flutter/flutter/issues/107130). The [DeltaTextInputClient] might be implemented by
/// any given widget in a subtree, or it might be implemented by a non-widget class, such as a controller.
/// This interface hides those details and ensures that the [DeltaTextInputClient] is available, by contract,
/// from whichever class implements this interface.
abstract class ImeInputOwner {
DeltaTextInputClient get imeClient;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:super_editor/src/infrastructure/attributed_text_styles.dart';
import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/hint_text.dart';
import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/text_scrollview.dart';
import 'package:super_editor/src/infrastructure/super_textfield/input_method_engine/_ime_text_editing_controller.dart';
import 'package:super_editor/src/infrastructure/super_textfield/input_method_engine/ime_input_owner.dart';
import 'package:super_editor/src/infrastructure/super_textfield/ios/_editing_controls.dart';
import 'package:super_text_layout/super_text_layout.dart';

Expand Down Expand Up @@ -132,7 +133,7 @@ class SuperIOSTextField extends StatefulWidget {

class SuperIOSTextFieldState extends State<SuperIOSTextField>
with SingleTickerProviderStateMixin
implements ProseTextBlock {
implements ProseTextBlock, ImeInputOwner {
final _textFieldKey = GlobalKey();
final _textFieldLayerLink = LayerLink();
final _textContentLayerLink = LayerLink();
Expand Down Expand Up @@ -200,7 +201,7 @@ class SuperIOSTextFieldState extends State<SuperIOSTextField>
_textEditingController
..removeListener(_onTextOrSelectionChange)
..onIOSFloatingCursorChange = null;
if (_textEditingController.onPerformActionPressed == _onPerformActionPressed){
if (_textEditingController.onPerformActionPressed == _onPerformActionPressed) {
_textEditingController.onPerformActionPressed = null;
}

Expand Down Expand Up @@ -274,6 +275,9 @@ class SuperIOSTextFieldState extends State<SuperIOSTextField>
@override
ProseTextLayout get textLayout => _textContentKey.currentState!.textLayout;

@override
DeltaTextInputClient get imeClient => _textEditingController;

bool get _isMultiline => (widget.minLines ?? 1) != 1 || (widget.maxLines ?? 1) != 1;

void _onFocusChange() {
Expand Down Expand Up @@ -370,7 +374,7 @@ class SuperIOSTextFieldState extends State<SuperIOSTextField>
break;
default:
_log.warning("User pressed unhandled action button: $action");
}
}
}

@override
Expand Down
2 changes: 1 addition & 1 deletion super_editor/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ dependency_overrides:

dev_dependencies:
flutter_lints: ^2.0.1
flutter_test_robots: ^0.0.15
flutter_test_robots: ^0.0.17
golden_toolkit: ^0.11.0
mockito: ^5.0.4
super_editor_markdown:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import 'package:flutter/material.dart' hide SelectableText;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_test_robots/flutter_test_robots.dart';
import 'package:super_editor/src/infrastructure/super_textfield/input_method_engine/ime_input_owner.dart';
import 'package:super_editor/super_editor.dart';

import '../test_tools.dart';
import 'super_textfield_inspector.dart';
import 'super_textfield_robot.dart';

void main() {
group('SuperTextField', () {
group('on mobile', () {
group('inserts character', () {
testWidgetsOnMobile('in empty text', (tester) async {
await _pumpEmptySuperTextField(tester);
await tester.placeCaretInSuperTextField(0);

await tester.ime.typeText("f", getter: imeClientGetter);

expect(SuperTextFieldInspector.findText().text, "f");
expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 1));
});

testWidgetsOnMobile('in middle of text', (tester) async {
await _pumpSuperTextField(
tester,
AttributedTextEditingController(
text: AttributedText(text: '--><--'),
),
);
await tester.placeCaretInSuperTextField(3);

await tester.ime.typeText("f", getter: imeClientGetter);

expect(SuperTextFieldInspector.findText().text, "-->f<--");
expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 4));
});

testWidgetsOnMobile('at end of text', (tester) async {
await _pumpSuperTextField(
tester,
AttributedTextEditingController(
text: AttributedText(text: '-->'),
),
);
await tester.placeCaretInSuperTextField(3);

await tester.ime.typeText("f", getter: imeClientGetter);

expect(SuperTextFieldInspector.findText().text, "-->f");
expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 4));
});

testWidgetsOnMobile('and replaces selected text', (tester) async {
// TODO: We create the controller outside the pump so that we can explicitly set its selection
// because we don't support gesture selection on mobile, yet.
final controller = AttributedTextEditingController(
text: AttributedText(text: '-->REPLACE<--'),
);
await _pumpSuperTextField(
tester,
controller,
);

// TODO: switch this to gesture selection when we support that on mobile
controller.selection = const TextSelection(baseOffset: 3, extentOffset: 10);

await tester.ime.typeText("f", getter: imeClientGetter);

expect(SuperTextFieldInspector.findText().text, "-->f<--");
expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 4));
});
});

// TODO: implement newline tests when SuperTextField supports configuration of the action button
// group('inserts line', () {
// testWidgetsOnDesktop('when ENTER is pressed in middle of text', (tester) async {
// await _pumpSuperTextField(
// tester,
// AttributedTextEditingController(
// text: AttributedText(text: 'this is some text'),
// ),
// );
// await tester.placeCaretInSuperTextField(8);
//
// await tester.pressEnter();
//
// expect(SuperTextFieldInspector.findText().text, "this is \nsome text");
// expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 9));
// });
//
// testWidgetsOnDesktop('when ENTER is pressed at beginning of text', (tester) async {
// await _pumpSuperTextField(
// tester,
// AttributedTextEditingController(
// text: AttributedText(text: 'this is some text'),
// ),
// );
// await tester.placeCaretInSuperTextField(0);
//
// await tester.pressEnter();
//
// expect(SuperTextFieldInspector.findText().text, "\nthis is some text");
// expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 1));
// });
//
// testWidgetsOnDesktop('when ENTER is pressed at end of text', (tester) async {
// await _pumpSuperTextField(
// tester,
// AttributedTextEditingController(
// text: AttributedText(text: 'this is some text'),
// ),
// );
// await tester.placeCaretInSuperTextField(17);
//
// await tester.pressEnter();
//
// expect(SuperTextFieldInspector.findText().text, "this is some text\n");
// expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 18));
// });
// });
//
group('delete text', () {
testWidgetsOnMobile('BACKSPACE does nothing when text is empty', (tester) async {
await _pumpSuperTextField(
tester,
AttributedTextEditingController(
text: AttributedText(text: ""),
),
);
await tester.placeCaretInSuperTextField(0);

await tester.ime.backspace(getter: imeClientGetter);

expect(SuperTextFieldInspector.findText().text, "");
expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0));
});

testWidgetsOnMobile('BACKSPACE deletes the previous character', (tester) async {
await _pumpSuperTextField(
tester,
AttributedTextEditingController(
text: AttributedText(text: "this is some text"),
),
);
await tester.placeCaretInSuperTextField(2);

await tester.ime.backspace(getter: imeClientGetter);

expect(SuperTextFieldInspector.findText().text, "tis is some text");
expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 1));
});

testWidgetsOnMobile('BACKSPACE deletes selection when selection is expanded', (tester) async {
// TODO: We create the controller outside the pump so that we can explicitly set its selection
// because we don't support gesture selection on mobile, yet.
final controller = AttributedTextEditingController(
text: AttributedText(text: _multilineLayoutText),
);
await _pumpSuperTextField(
tester,
controller,
);

// TODO: switch this to gesture selection when we support that on mobile
controller.selection = const TextSelection(baseOffset: 0, extentOffset: 10);

await tester.ime.backspace(getter: imeClientGetter);

expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0));
expect(SuperTextFieldInspector.findText().text, "is long enough to be multiline in the available space");
});
});
});
});
}

// Based on experiments, the text is laid out as follows (at 320px wide):
//
// (0)this text is long (18 - upstream)
// (18)enough to be (31 - upstream)
// (31)multiline in the (48 - upstream)
// (48)available space(63)
const _multilineLayoutText = 'this text is long enough to be multiline in the available space';

Future<void> _pumpEmptySuperTextField(WidgetTester tester) async {
await _pumpSuperTextField(
tester,
AttributedTextEditingController(text: AttributedText(text: '')),
);
}

Future<void> _pumpSuperTextField(
WidgetTester tester,
AttributedTextEditingController controller, {
int? minLines,
int? maxLines,
}) async {
await tester.pumpWidget(
MaterialApp(
// The Center allows the content to be smaller than the display
home: Center(
// This SizedBox, combined with the font size in the TextStyle,
// determines the text line wrapping, which is critical for the
// tests in this suite.
child: SizedBox(
width: 320,
child: SuperTextField(
textController: controller,
minLines: minLines,
maxLines: maxLines,
lineHeight: 18,
textStyleBuilder: (_) {
return const TextStyle(
// This font size, combined with the layout width below, are
// critical to determining the text line wrapping.
fontSize: 18,
);
},
),
),
),
),
);
await tester.pumpAndSettle();

// The following code prints the bounding box for every
// character of text in the layout. You can use that info
// to figure out where line breaks occur.
// final textLayout = SuperTextFieldInspector.findProseTextLayout();
// for (int i = 0; i < _multilineLayoutText.length; ++i) {
// print('$i: ${textLayout.getCharacterBox(TextPosition(offset: i))}');
// }
}

DeltaTextInputClient imeClientGetter() {
final element = find
.byElementPredicate((element) => element is StatefulElement && element.state is ImeInputOwner)
.evaluate()
.single as StatefulElement;
final owner = element.state as ImeInputOwner;
return owner.imeClient;
}
Loading

0 comments on commit 9798e10

Please sign in to comment.