mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Merge pull request #844 from LucasXu0/feat/widget_test
feat: implement editor test infra
This commit is contained in:
commit
fb8234b399
6
.github/workflows/dart_test.yml
vendored
6
.github/workflows/dart_test.yml
vendored
@ -78,9 +78,3 @@ jobs:
|
||||
run: |
|
||||
flutter pub get
|
||||
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
36
.github/workflows/flowy_editor_test.yml
vendored
Normal 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
|
@ -6,6 +6,7 @@ export 'src/document/position.dart';
|
||||
export 'src/document/selection.dart';
|
||||
export 'src/document/state_tree.dart';
|
||||
export 'src/document/text_delta.dart';
|
||||
export 'src/document/attributes.dart';
|
||||
export 'src/editor_state.dart';
|
||||
export 'src/operation/operation.dart';
|
||||
export 'src/operation/transaction.dart';
|
||||
|
@ -111,6 +111,27 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
|
||||
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
|
||||
void insertAfter(Node entry) {
|
||||
entry.parent = parent;
|
||||
|
@ -1,3 +1,5 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flowy_editor/src/document/node.dart';
|
||||
import 'package:flowy_editor/src/document/path.dart';
|
||||
import 'package:flowy_editor/src/document/text_delta.dart';
|
||||
@ -27,9 +29,18 @@ class StateTree {
|
||||
return false;
|
||||
}
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
|
@ -51,6 +51,9 @@ class EditorState {
|
||||
final UndoManager undoManager = UndoManager();
|
||||
Selection? _cursorSelection;
|
||||
|
||||
/// TODO: only for testing.
|
||||
bool disableSealTimer = false;
|
||||
|
||||
Selection? get cursorSelection {
|
||||
return _cursorSelection;
|
||||
}
|
||||
@ -106,6 +109,9 @@ class EditorState {
|
||||
}
|
||||
|
||||
_debouncedSealHistoryItem() {
|
||||
if (disableSealTimer) {
|
||||
return;
|
||||
}
|
||||
_debouncedSealHistoryItemTimer?.cancel();
|
||||
_debouncedSealHistoryItemTimer =
|
||||
Timer(const Duration(milliseconds: 1000), () {
|
||||
|
@ -24,11 +24,18 @@ FlowyKeyEventHandler enterWithoutShiftInTextNodesHandler =
|
||||
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 selection = editorState.service.selectionService.currentSelection.value;
|
||||
|
||||
if (selection == null || nodes.length != textNodes.length) {
|
||||
if (nodes.length != textNodes.length) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@ -36,7 +43,7 @@ FlowyKeyEventHandler enterWithoutShiftInTextNodesHandler =
|
||||
if (!selection.isSingle) {
|
||||
final length = textNodes.length;
|
||||
final List<TextNode> subTextNodes =
|
||||
length >= 3 ? textNodes.sublist(1, textNodes.length - 2) : [];
|
||||
length >= 3 ? textNodes.sublist(1, textNodes.length - 1) : [];
|
||||
final afterSelection = Selection.collapsed(
|
||||
Position(path: textNodes.first.path.next, offset: 0),
|
||||
);
|
||||
@ -86,7 +93,7 @@ FlowyKeyEventHandler enterWithoutShiftInTextNodesHandler =
|
||||
);
|
||||
TransactionBuilder(editorState)
|
||||
..insertNode(
|
||||
textNode.path.next,
|
||||
textNode.path,
|
||||
TextNode.empty(),
|
||||
)
|
||||
..afterSelection = afterSelection
|
||||
|
@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
abstract class FlowyKeyboardService {
|
||||
KeyEventResult onKey(RawKeyEvent event);
|
||||
void enable();
|
||||
void disable();
|
||||
}
|
||||
@ -65,15 +66,8 @@ class _FlowyKeyboardState extends State<FlowyKeyboard>
|
||||
_focusNode.unfocus();
|
||||
}
|
||||
|
||||
void _onFocusChange(bool value) {
|
||||
debugPrint('[KeyBoard Service] focus change $value');
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, RawKeyEvent event) {
|
||||
if (!isFocus) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
KeyEventResult onKey(RawKeyEvent event) {
|
||||
debugPrint('on keyboard event $event');
|
||||
|
||||
if (event is! RawKeyDownEvent) {
|
||||
@ -97,4 +91,16 @@ class _FlowyKeyboardState extends State<FlowyKeyboard>
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -77,7 +77,8 @@ class FlowyRenderPlugin extends FlowyRenderPluginService {
|
||||
node.key = key;
|
||||
return _autoUpdateNodeWidget(builder, context);
|
||||
} 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.
|
||||
return Container();
|
||||
}
|
||||
|
@ -187,11 +187,11 @@ class _FlowySelectionState extends State<FlowySelection>
|
||||
if (selection != null) {
|
||||
if (selection.isCollapsed) {
|
||||
/// updates cursor area.
|
||||
debugPrint('updating cursor');
|
||||
debugPrint('updating cursor, $selection');
|
||||
_updateCursorAreas(selection.start);
|
||||
} else {
|
||||
// updates selection area.
|
||||
debugPrint('updating selection');
|
||||
debugPrint('updating selection, $selection');
|
||||
_updateSelectionAreas(selection);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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 😁');
|
||||
}
|
Loading…
Reference in New Issue
Block a user