Ver código fonte

feat: implement editor test infra

Lucas.Xu 2 anos atrás
pai
commit
a6bba5a0f9

+ 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/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';

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

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

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

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

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

+ 69 - 0
frontend/app_flowy/packages/flowy_editor/test/editor/key_event_tests/enter_without_shift_in_text_node_handler_test.dart

@@ -0,0 +1,69 @@
+import 'package:flowy_editor/flowy_editor.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 key without shift handler', () {
+    testWidgets('Pressing enter key in empty document', (tester) async {
+      final editor = tester.editor
+        ..initialize()
+        ..insertEmptyTextNode();
+      await editor.startTesting();
+      await editor.updateSelection(
+        Selection.collapsed(
+          Position(path: [0], offset: 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.collapsed(Position(path: [i], offset: 0)));
+      }
+    });
+
+    testWidgets('Pressing enter key in non-empty document', (tester) async {
+      const text = 'Welcome to Appflowy 😁';
+      var lines = 5;
+
+      final editor = tester.editor..initialize();
+      for (var i = 1; i <= lines; i++) {
+        editor.insertTextNode(text: text);
+      }
+      await editor.startTesting();
+
+      expect(editor.documentLength, lines);
+
+      // Pressing the enter key in last line.
+      await editor.updateSelection(
+        Selection.collapsed(
+          Position(path: [lines - 1], offset: 0),
+        ),
+      );
+      await editor.pressLogicKey(
+        LogicalKeyboardKey.enter,
+      );
+      lines += 1;
+
+      expect(editor.documentLength, lines);
+      expect(editor.documentSelection,
+          Selection.collapsed(Position(path: [lines - 1], offset: 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);
+    });
+  });
+}

+ 39 - 0
frontend/app_flowy/packages/flowy_editor/test/editor/widget_test.dart

@@ -0,0 +1,39 @@
+import 'dart:convert';
+import 'dart:io';
+
+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 '../infra/test_raw_key_event.dart';
+
+void main() async {
+  final file = File('test_assets/example.json');
+  final json = jsonDecode(await file.readAsString());
+  print(json);
+
+  setUpAll(() {
+    TestWidgetsFlutterBinding.ensureInitialized();
+  });
+
+  testWidgets('init FlowyEditor ', (tester) async {
+    final editorState = EditorState(
+      document: StateTree.fromJson(json),
+    );
+    final flowyEditor = FlowyEditor(editorState: editorState);
+    await tester.pumpWidget(MaterialApp(
+      home: flowyEditor,
+    ));
+    editorState.service.selectionService
+        .updateSelection(Selection.collapsed(Position(path: [0], offset: 1)));
+    await tester.pumpAndSettle();
+    final key = const TestRawKeyEventData(
+      logicalKey: LogicalKeyboardKey.enter,
+      physicalKey: PhysicalKeyboardKey.enter,
+    ).toKeyEvent;
+    editorState.service.keyboardService!.onKey(key);
+    await tester.pumpAndSettle();
+    expect(editorState.document.root.children.length, 2);
+  });
+}

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

@@ -0,0 +1,107 @@
+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();
+  }
+
+  insert<T extends Node>(T node) {
+    _editorState.document.root.insert(node);
+  }
+
+  insertEmptyTextNode() {
+    insert(TextNode.empty());
+  }
+
+  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);
+  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