Quellcode durchsuchen

Merge pull request #844 from LucasXu0/feat/widget_test

feat: implement editor test infra
Lucas.Xu vor 2 Jahren
Ursprung
Commit
fb8234b399

+ 0 - 6
.github/workflows/dart_test.yml

@@ -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 - 0
.github/workflows/flowy_editor_test.yml

@@ -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

+ 1 - 0
frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart

@@ -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';

+ 21 - 0
frontend/app_flowy/packages/flowy_editor/lib/src/document/node.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;

+ 12 - 1
frontend/app_flowy/packages/flowy_editor/lib/src/document/state_tree.dart

@@ -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++) {

+ 6 - 0
frontend/app_flowy/packages/flowy_editor/lib/src/editor_state.dart

@@ -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), () {

+ 12 - 5
frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart

@@ -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

+ 15 - 9
frontend/app_flowy/packages/flowy_editor/lib/src/service/keyboard_service.dart

@@ -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);
+  }
 }

+ 2 - 1
frontend/app_flowy/packages/flowy_editor/lib/src/service/render_plugin_service.dart

@@ -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();
     }

+ 2 - 2
frontend/app_flowy/packages/flowy_editor/lib/src/service/selection_service.dart

@@ -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);
       }
     }

+ 108 - 0
frontend/app_flowy/packages/flowy_editor/test/infra/test_editor.dart

@@ -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;
+}

+ 52 - 0
frontend/app_flowy/packages/flowy_editor/test/infra/test_raw_key_event.dart

@@ -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
frontend/app_flowy/packages/flowy_editor/test/delta_test.dart → frontend/app_flowy/packages/flowy_editor/test/legacy/delta_test.dart


+ 0 - 0
frontend/app_flowy/packages/flowy_editor/test/flowy_editor_test.dart → frontend/app_flowy/packages/flowy_editor/test/legacy/flowy_editor_test.dart


+ 0 - 0
frontend/app_flowy/packages/flowy_editor/test/operation_test.dart → frontend/app_flowy/packages/flowy_editor/test/legacy/operation_test.dart


+ 198 - 0
frontend/app_flowy/packages/flowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart

@@ -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 😁');
+}