Merge pull request #844 from LucasXu0/feat/widget_test

feat: implement editor test infra
This commit is contained in:
Lucas.Xu 2022-08-15 12:13:29 +08:00 committed by GitHub
commit fb8234b399
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 465 additions and 24 deletions

View File

@ -78,9 +78,3 @@ jobs:
run: | run: |
flutter pub get flutter pub get
flutter test flutter test
- name: Run FlowyEditor tests
working-directory: frontend/app_flowy/packages/flowy_editor
run: |
flutter pub get
flutter test

36
.github/workflows/flowy_editor_test.yml vendored Normal file
View File

@ -0,0 +1,36 @@
name: FlowyEditor test
on:
push:
branches:
- "main"
pull_request:
branches:
- "main"
env:
CARGO_TERM_COLOR: always
jobs:
tests:
strategy:
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v2
- uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.0.5"
cache: true
- name: Run FlowyEditor tests
working-directory: frontend/app_flowy/packages/flowy_editor
run: |
flutter pub get
flutter test

View File

@ -6,6 +6,7 @@ export 'src/document/position.dart';
export 'src/document/selection.dart'; export 'src/document/selection.dart';
export 'src/document/state_tree.dart'; export 'src/document/state_tree.dart';
export 'src/document/text_delta.dart'; export 'src/document/text_delta.dart';
export 'src/document/attributes.dart';
export 'src/editor_state.dart'; export 'src/editor_state.dart';
export 'src/operation/operation.dart'; export 'src/operation/operation.dart';
export 'src/operation/transaction.dart'; export 'src/operation/transaction.dart';

View File

@ -111,6 +111,27 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
return childAtIndex(path.first)?.childAtPath(path.sublist(1)); return childAtIndex(path.first)?.childAtPath(path.sublist(1));
} }
void insert(Node entry, {int? index}) {
index ??= children.length;
if (children.isEmpty) {
entry.parent = this;
children.add(entry);
notifyListeners();
return;
}
final length = children.length;
if (index >= length) {
children.last.insertAfter(entry);
} else if (index <= 0) {
children.first.insertBefore(entry);
} else {
childAtIndex(index)?.insertBefore(entry);
}
}
@override @override
void insertAfter(Node entry) { void insertAfter(Node entry) {
entry.parent = parent; entry.parent = parent;

View File

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flowy_editor/src/document/node.dart'; import 'package:flowy_editor/src/document/node.dart';
import 'package:flowy_editor/src/document/path.dart'; import 'package:flowy_editor/src/document/path.dart';
import 'package:flowy_editor/src/document/text_delta.dart'; import 'package:flowy_editor/src/document/text_delta.dart';
@ -27,9 +29,18 @@ class StateTree {
return false; return false;
} }
Node? insertedNode = root.childAtPath( Node? insertedNode = root.childAtPath(
path.sublist(0, path.length - 1) + [path.last - 1], path.sublist(0, path.length - 1) + [max(0, path.last - 1)],
); );
if (insertedNode == null) { if (insertedNode == null) {
final insertedNode = root.childAtPath(
path.sublist(0, path.length - 1),
);
if (insertedNode != null) {
for (final node in nodes) {
insertedNode.insert(node);
}
return true;
}
return false; return false;
} }
for (var i = 0; i < nodes.length; i++) { for (var i = 0; i < nodes.length; i++) {

View File

@ -51,6 +51,9 @@ class EditorState {
final UndoManager undoManager = UndoManager(); final UndoManager undoManager = UndoManager();
Selection? _cursorSelection; Selection? _cursorSelection;
/// TODO: only for testing.
bool disableSealTimer = false;
Selection? get cursorSelection { Selection? get cursorSelection {
return _cursorSelection; return _cursorSelection;
} }
@ -106,6 +109,9 @@ class EditorState {
} }
_debouncedSealHistoryItem() { _debouncedSealHistoryItem() {
if (disableSealTimer) {
return;
}
_debouncedSealHistoryItemTimer?.cancel(); _debouncedSealHistoryItemTimer?.cancel();
_debouncedSealHistoryItemTimer = _debouncedSealHistoryItemTimer =
Timer(const Duration(milliseconds: 1000), () { Timer(const Duration(milliseconds: 1000), () {

View File

@ -24,11 +24,18 @@ FlowyKeyEventHandler enterWithoutShiftInTextNodesHandler =
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
final nodes = editorState.service.selectionService.currentSelectedNodes; var selection = editorState.service.selectionService.currentSelection.value;
var nodes = editorState.service.selectionService.currentSelectedNodes;
if (selection == null) {
return KeyEventResult.ignored;
}
if (selection.isForward) {
selection = selection.reversed;
nodes = nodes.reversed.toList(growable: false);
}
final textNodes = nodes.whereType<TextNode>().toList(growable: false); final textNodes = nodes.whereType<TextNode>().toList(growable: false);
final selection = editorState.service.selectionService.currentSelection.value;
if (selection == null || nodes.length != textNodes.length) { if (nodes.length != textNodes.length) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
@ -36,7 +43,7 @@ FlowyKeyEventHandler enterWithoutShiftInTextNodesHandler =
if (!selection.isSingle) { if (!selection.isSingle) {
final length = textNodes.length; final length = textNodes.length;
final List<TextNode> subTextNodes = final List<TextNode> subTextNodes =
length >= 3 ? textNodes.sublist(1, textNodes.length - 2) : []; length >= 3 ? textNodes.sublist(1, textNodes.length - 1) : [];
final afterSelection = Selection.collapsed( final afterSelection = Selection.collapsed(
Position(path: textNodes.first.path.next, offset: 0), Position(path: textNodes.first.path.next, offset: 0),
); );
@ -86,7 +93,7 @@ FlowyKeyEventHandler enterWithoutShiftInTextNodesHandler =
); );
TransactionBuilder(editorState) TransactionBuilder(editorState)
..insertNode( ..insertNode(
textNode.path.next, textNode.path,
TextNode.empty(), TextNode.empty(),
) )
..afterSelection = afterSelection ..afterSelection = afterSelection

View File

@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
abstract class FlowyKeyboardService { abstract class FlowyKeyboardService {
KeyEventResult onKey(RawKeyEvent event);
void enable(); void enable();
void disable(); void disable();
} }
@ -65,15 +66,8 @@ class _FlowyKeyboardState extends State<FlowyKeyboard>
_focusNode.unfocus(); _focusNode.unfocus();
} }
void _onFocusChange(bool value) { @override
debugPrint('[KeyBoard Service] focus change $value'); KeyEventResult onKey(RawKeyEvent event) {
}
KeyEventResult _onKey(FocusNode node, RawKeyEvent event) {
if (!isFocus) {
return KeyEventResult.ignored;
}
debugPrint('on keyboard event $event'); debugPrint('on keyboard event $event');
if (event is! RawKeyDownEvent) { if (event is! RawKeyDownEvent) {
@ -97,4 +91,16 @@ class _FlowyKeyboardState extends State<FlowyKeyboard>
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
void _onFocusChange(bool value) {
debugPrint('[KeyBoard Service] focus change $value');
}
KeyEventResult _onKey(FocusNode node, RawKeyEvent event) {
if (!isFocus) {
return KeyEventResult.ignored;
}
return onKey(event);
}
} }

View File

@ -77,7 +77,8 @@ class FlowyRenderPlugin extends FlowyRenderPluginService {
node.key = key; node.key = key;
return _autoUpdateNodeWidget(builder, context); return _autoUpdateNodeWidget(builder, context);
} else { } else {
assert(false, 'Could not query the builder with this $name'); assert(false,
'Could not query the builder with this $name, or nodeValidator return false.');
// TODO: return a placeholder widget with tips. // TODO: return a placeholder widget with tips.
return Container(); return Container();
} }

View File

@ -187,11 +187,11 @@ class _FlowySelectionState extends State<FlowySelection>
if (selection != null) { if (selection != null) {
if (selection.isCollapsed) { if (selection.isCollapsed) {
/// updates cursor area. /// updates cursor area.
debugPrint('updating cursor'); debugPrint('updating cursor, $selection');
_updateCursorAreas(selection.start); _updateCursorAreas(selection.start);
} else { } else {
// updates selection area. // updates selection area.
debugPrint('updating selection'); debugPrint('updating selection, $selection');
_updateSelectionAreas(selection); _updateSelectionAreas(selection);
} }
} }

View File

@ -0,0 +1,108 @@
import 'dart:collection';
import 'package:flowy_editor/flowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'test_raw_key_event.dart';
class EditorWidgetTester {
EditorWidgetTester({
required this.tester,
});
final WidgetTester tester;
late EditorState _editorState;
EditorState get editorState => _editorState;
Node get root => _editorState.document.root;
int get documentLength => _editorState.document.root.children.length;
Selection? get documentSelection =>
_editorState.service.selectionService.currentSelection.value;
Future<EditorWidgetTester> startTesting() async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FlowyEditor(
editorState: _editorState,
),
),
),
);
return this;
}
void initialize() {
_editorState = _createEmptyDocument();
}
void insert<T extends Node>(T node) {
_editorState.document.root.insert(node);
}
void insertEmptyTextNode() {
insert(TextNode.empty());
}
void insertTextNode(String? text, {Attributes? attributes}) {
insert(
TextNode(
type: 'text',
delta: Delta(
[TextInsert(text ?? 'Test')],
),
attributes: attributes,
),
);
}
Node? nodeAtPath(Path path) {
return root.childAtPath(path);
}
Future<void> updateSelection(Selection? selection) async {
if (selection == null) {
_editorState.service.selectionService.clearSelection();
} else {
_editorState.service.selectionService.updateSelection(selection);
}
await tester.pumpAndSettle();
}
Future<void> pressLogicKey(LogicalKeyboardKey key) async {
late RawKeyEvent testRawKeyEventData;
if (key == LogicalKeyboardKey.enter) {
testRawKeyEventData = const TestRawKeyEventData(
logicalKey: LogicalKeyboardKey.enter,
physicalKey: PhysicalKeyboardKey.enter,
).toKeyEvent;
}
_editorState.service.keyboardService!.onKey(testRawKeyEventData);
await tester.pumpAndSettle();
}
Node _createEmptyEditorRoot() {
return Node(
type: 'editor',
children: LinkedList(),
attributes: {},
);
}
EditorState _createEmptyDocument() {
return EditorState(
document: StateTree(
root: _createEmptyEditorRoot(),
),
)..disableSealTimer = true;
}
}
extension TestEditorExtension on WidgetTester {
EditorWidgetTester get editor =>
EditorWidgetTester(tester: this)..initialize();
EditorState get editorState => editor.editorState;
}

View File

@ -0,0 +1,52 @@
import 'package:flutter/services.dart';
class TestRawKeyEvent extends RawKeyDownEvent {
const TestRawKeyEvent({required super.data});
}
class TestRawKeyEventData extends RawKeyEventData {
const TestRawKeyEventData({
required this.logicalKey,
required this.physicalKey,
this.isControlPressed = false,
this.isShiftPressed = false,
this.isAltPressed = false,
this.isMetaPressed = false,
});
@override
final bool isControlPressed;
@override
final bool isShiftPressed;
@override
final bool isAltPressed;
@override
final bool isMetaPressed;
@override
final LogicalKeyboardKey logicalKey;
@override
final PhysicalKeyboardKey physicalKey;
@override
KeyboardSide? getModifierSide(ModifierKey key) {
throw UnimplementedError();
}
@override
bool isModifierPressed(ModifierKey key,
{KeyboardSide side = KeyboardSide.any}) {
throw UnimplementedError();
}
@override
String get keyLabel => throw UnimplementedError();
RawKeyEvent get toKeyEvent {
return TestRawKeyEvent(data: this);
}
}

View File

@ -0,0 +1,198 @@
import 'package:flowy_editor/flowy_editor.dart';
import 'package:flowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../infra/test_editor.dart';
void main() async {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('enter_without_shift_in_text_node_handler.dart', () {
testWidgets('Presses enter key in empty document', (tester) async {
// Before
//
// [Empty Line]
//
// After
//
// [Empty Line] * 10
//
final editor = tester.editor..insertEmptyTextNode();
await editor.startTesting();
await editor.updateSelection(
Selection.single(path: [0], startOffset: 0),
);
// Pressing the enter key continuously.
for (int i = 1; i <= 10; i++) {
await editor.pressLogicKey(
LogicalKeyboardKey.enter,
);
expect(editor.documentLength, i + 1);
expect(editor.documentSelection,
Selection.single(path: [i], startOffset: 0));
}
});
testWidgets('Presses enter key in non-empty document', (tester) async {
// Before
//
// Welcome to Appflowy 😁
// Welcome to Appflowy 😁
// Welcome to Appflowy 😁
//
// After
//
// Welcome to Appflowy 😁
// Welcome to Appflowy 😁
// [Empty Line]
// Welcome to Appflowy 😁
//
const text = 'Welcome to Appflowy 😁';
var lines = 3;
final editor = tester.editor;
for (var i = 1; i <= lines; i++) {
editor.insertTextNode(text);
}
await editor.startTesting();
expect(editor.documentLength, lines);
// Presses the enter key in last line.
await editor.updateSelection(
Selection.single(path: [lines - 1], startOffset: 0),
);
await editor.pressLogicKey(
LogicalKeyboardKey.enter,
);
lines += 1;
expect(editor.documentLength, lines);
expect(editor.documentSelection,
Selection.single(path: [lines - 1], startOffset: 0));
var lastNode = editor.nodeAtPath([lines - 1]);
expect(lastNode != null, true);
expect(lastNode is TextNode, true);
lastNode = lastNode as TextNode;
expect(lastNode.delta.toRawString(), text);
expect((lastNode.previous as TextNode).delta.toRawString(), '');
expect(
(lastNode.previous!.previous as TextNode).delta.toRawString(), text);
});
// Before
//
// Welcome to Appflowy 😁
// [Style] Welcome to Appflowy 😁
// [Style] Welcome to Appflowy 😁
//
// After
//
// Welcome to Appflowy 😁
// [Empty Line]
// [Style] Welcome to Appflowy 😁
// [Style] Welcome to Appflowy 😁
// [Style]
testWidgets('Presses enter key in bulleted list', (tester) async {
await _testStyleNeedToBeCopy(tester, StyleKey.bulletedList);
});
testWidgets('Presses enter key in numbered list', (tester) async {
await _testStyleNeedToBeCopy(tester, StyleKey.numberList);
});
testWidgets('Presses enter key in checkbox styled text', (tester) async {
await _testStyleNeedToBeCopy(tester, StyleKey.checkbox);
});
testWidgets('Presses enter key in quoted text', (tester) async {
await _testStyleNeedToBeCopy(tester, StyleKey.quote);
});
testWidgets('Presses enter key in multiple selection from top to bottom',
(tester) async {
_testMultipleSelection(tester, true);
});
testWidgets('Presses enter key in multiple selection from bottom to top',
(tester) async {
_testMultipleSelection(tester, false);
});
});
}
Future<void> _testStyleNeedToBeCopy(WidgetTester tester, String style) async {
const text = 'Welcome to Appflowy 😁';
Attributes attributes = {
StyleKey.subtype: style,
};
if (style == StyleKey.checkbox) {
attributes[StyleKey.checkbox] = true;
} else if (style == StyleKey.numberList) {
attributes[StyleKey.number] = 1;
}
final editor = tester.editor
..insertTextNode(text)
..insertTextNode(text, attributes: attributes)
..insertTextNode(text, attributes: attributes);
await editor.startTesting();
await editor.updateSelection(
Selection.single(path: [1], startOffset: 0),
);
await editor.pressLogicKey(
LogicalKeyboardKey.enter,
);
expect(editor.documentSelection, Selection.single(path: [2], startOffset: 0));
await editor.updateSelection(
Selection.single(path: [3], startOffset: text.length),
);
await editor.pressLogicKey(
LogicalKeyboardKey.enter,
);
expect(editor.documentSelection, Selection.single(path: [4], startOffset: 0));
expect(editor.nodeAtPath([4])?.subtype, style);
await editor.pressLogicKey(
LogicalKeyboardKey.enter,
);
expect(editor.documentSelection, Selection.single(path: [4], startOffset: 0));
expect(editor.nodeAtPath([4])?.subtype, null);
}
Future<void> _testMultipleSelection(
WidgetTester tester, bool isBackwardSelection) async {
// Before
//
// Welcome to Appflowy 😁
// Welcome to Appflowy 😁
// Welcome to Appflowy 😁
// Welcome to Appflowy 😁
//
// After
//
// Welcome
// to Appflowy 😁
//
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor;
var lines = 4;
for (var i = 1; i <= lines; i++) {
editor.insertTextNode(text);
}
await editor.startTesting();
final start = Position(path: [0], offset: 7);
final end = Position(path: [3], offset: 8);
await editor.updateSelection(Selection(
start: isBackwardSelection ? start : end,
end: isBackwardSelection ? end : start,
));
await editor.pressLogicKey(
LogicalKeyboardKey.enter,
);
expect(editor.documentLength, 2);
expect((editor.nodeAtPath([0]) as TextNode).toRawString(), 'Welcome');
expect((editor.nodeAtPath([1]) as TextNode).toRawString(), 'to Appflowy 😁');
}