Repository: akvelon/flutter-code-editor (dart)

Issue:
Foldable block matcher doesn't work properly
From the code below
```java
class MyClass {
  /// Calculates the factorial of the number.
  /// The number must be >= 0.
  static int factorial(int n) {
    if (n == 0) {
      return 1;
    }

    return n * factorial(n - 1);
  }

  public static void main(String[] args) {
    int num = 5;s
    System.out.println("Factorial of " + num + " is " + factorial(5));
  }
}
```
Steps to reproduce:

- Fold at the 4th line
- Move cursor to the end of 13th line
- Enter any character

`Expected`
- character is inserted to the text at cursor location.
- folded block preserve its folding

`Actual`:
- character is inserted at the end of 13th line
- folded block is unfolded
- cursor is moved to the end of 4th line

Pull request:
- Resolves #131

CC https://github.com/apache/beam/issues/25279

Patch:
diff --git a/lib/src/code/code.dart b/lib/src/code/code.dart
index 04ebf561..1770a9ac 100644
--- a/lib/src/code/code.dart
+++ b/lib/src/code/code.dart
@@ -435,9 +435,10 @@ class Code {
   }
 
   FoldableBlock? _getFoldableBlockByStartLine(int line) {
-    return foldableBlocks.firstWhereOrNull(
-      (block) => block.firstLine == line,
-    );
+    // It is important that we try to take a block from folded blocks first
+    // and only then from foldable blocks.
+    return foldedBlocks.firstWhereOrNull((block) => block.firstLine == line) ??
+        foldableBlocks.firstWhereOrNull((block) => block.firstLine == line);
   }
 
   HiddenRange foldableBlockToHiddenRange(FoldableBlock block) {
@@ -462,7 +463,6 @@ class Code {
   /// Folds this code at the same blocks as the [oldCode] is.
   Code foldedAs(Code oldCode) {
     final matcher = FoldableBlockMatcher(
-      oldBlocks: oldCode.foldableBlocks,
       oldLines: oldCode.lines.lines,
       newBlocks: foldableBlocks,
       newLines: lines.lines,
diff --git a/lib/src/folding/foldable_block.dart b/lib/src/folding/foldable_block.dart
index 323e5ca0..d6716593 100644
--- a/lib/src/folding/foldable_block.dart
+++ b/lib/src/folding/foldable_block.dart
@@ -68,6 +68,14 @@ class FoldableBlock extends InclusiveRange with EquatableMixin {
     );
   }
 
+  FoldableBlock offset(int line) {
+    return FoldableBlock(
+      firstLine: firstLine + line,
+      lastLine: lastLine + line,
+      type: type,
+    );
+  }
+
   static FoldableBlockType _getJoinType(FoldableBlock a, FoldableBlock b) {
     if (a.type == FoldableBlockType.imports) {
       return FoldableBlockType.imports;
diff --git a/lib/src/folding/foldable_block_matcher.dart b/lib/src/folding/foldable_block_matcher.dart
index 55e403e1..e5e93d2f 100644
--- a/lib/src/folding/foldable_block_matcher.dart
+++ b/lib/src/folding/foldable_block_matcher.dart
@@ -1,91 +1,112 @@
-import 'dart:math';
-
 import '../code/code_line.dart';
+import '../code/string.dart';
 import 'foldable_block.dart';
 
-/// Matches foldable blocks before and after an edit to preserve their
-/// folding state.
+/// Matches folded blocks from the old code to the new code.
 ///
-/// Blocks match if they go in the same order and have the same content.
+/// Basically adds the folded block to the new code
+/// if the content of its hidden part is not changed.
+/// IMPORTANT: even if it is no longer a valid block after a change.
 class FoldableBlockMatcher {
   final List<CodeLine> oldLines;
   final List<FoldableBlock> newBlocks;
   final List<CodeLine> newLines;
   final oldToNew = <FoldableBlock, FoldableBlock>{};
   final newFoldedBlocks = <FoldableBlock>{};
+  final newFoldableBlocksMap = <int, FoldableBlock>{};
 
   FoldableBlockMatcher({
-    required List<FoldableBlock> oldBlocks,
     required this.oldLines,
     required this.newBlocks,
     required this.newLines,
     required Set<FoldableBlock> oldFoldedBlocks,
   }) {
-    if (oldBlocks.isEmpty || newBlocks.isEmpty) {
+    // If no foldable blocks were folded, there is nothing to match.
+    // Because we generate foldable blocks anyway.
+    if (oldFoldedBlocks.isEmpty) {
       return;
     }
 
-    // Walk top to bottom until the first mismatch, if any.
-    final minLength = min(oldBlocks.length, newBlocks.length);
-    int top = 0;
+    for (final block in newBlocks) {
+      newFoldableBlocksMap.addAll({
+        block.firstLine: block,
+      });
+    }
 
-    for (; top < minLength; top++) {
-      final oldBlock = oldBlocks[top];
-      final newBlock = newBlocks[top];
+    var firstDiffLineIndex = 0;
+    int oldLinesIndex = 0;
+    int newLinesIndex = 0;
 
-      if (_match(oldBlock: oldBlock, newBlock: newBlock)) {
-        oldToNew[oldBlock] = newBlock;
-      } else {
+    while (oldLinesIndex < oldLines.length && newLinesIndex < newLines.length) {
+      if (oldLines[oldLinesIndex].text != newLines[newLinesIndex].text) {
         break;
       }
+      firstDiffLineIndex++;
+      oldLinesIndex++;
+      newLinesIndex++;
     }
 
-    // top is now the first mismatch.
-
-    int oldBottom = oldBlocks.length - 1;
-    int newBottom = newBlocks.length - 1;
+    // This is basically the line-length of a inserted/removed text.
+    final lineCountDelta = newLines.length - oldLines.length;
 
-    for (; oldBottom >= top && newBottom >= top; oldBottom--, newBottom--) {
-      final oldBlock = oldBlocks[oldBottom];
-      final newBlock = newBlocks[newBottom];
-
-      if (_match(oldBlock: oldBlock, newBlock: newBlock)) {
-        oldToNew[oldBlock] = newBlock;
+    for (final foldedBlock in oldFoldedBlocks) {
+      if (foldedBlock.firstLine < firstDiffLineIndex) {
+        // If the folded block is located before changed part,
+        // the lines must match perfectly.
+        _addIfMatch(foldedBlock, 0);
       } else {
-        break;
+        // If the folded block is located after changed part,
+        // the lines must differ exactly to the lineCountDelta.
+        // Covers:
+        // 1. Paste text with undefined amount of newlines.
+        // 2. Remove/Cut text with undefined amount of newlines.
+        // 3. Add new line symbol.
+        _addIfMatch(foldedBlock, lineCountDelta);
       }
     }
+  }
 
-    for (final block in oldFoldedBlocks) {
-      final newBlock = oldToNew[block];
-      if (newBlock != null) {
-        newFoldedBlocks.add(newBlock);
-      }
+  /// Adds the folded block to newFoldedBlocks
+  /// if its hidden part is not changed.
+  ///
+  /// A valid match is when the inner content of a folded block is unchanged.
+  /// Lines must differ to exactly [lineCountDelta] lines.
+  void _addIfMatch(FoldableBlock oldFoldedBlock, int lineCountDelta) {
+    int newLinesIndex = oldFoldedBlock.firstLine + lineCountDelta;
+    int oldLinesIndex = oldFoldedBlock.firstLine;
+
+    if (oldLinesIndex < 0 || newLinesIndex < 0) {
+      return;
     }
-  }
 
-  bool _match({
-    required FoldableBlock oldBlock,
-    required FoldableBlock newBlock,
-  }) {
-    if (oldBlock.lineCount != newBlock.lineCount) {
-      return false;
+    // If the first line is removed completely, destroy the block.
+    if (newLines[newLinesIndex].text.hasOnlyWhitespaces()) {
+      return;
     }
 
-    // Allow the blocks to differ in the first line.
-    // This keeps a block folded when editing its first line.
-    int oldLineIndex = oldBlock.firstLine + 1;
-    int newLineIndex = newBlock.firstLine + 1;
+    // Allow blocks content to differ at the first line
+    newLinesIndex++;
+    oldLinesIndex++;
 
-    while (oldLineIndex <= oldBlock.lastLine) {
-      if (oldLines[oldLineIndex].text != newLines[newLineIndex].text) {
-        return false;
+    while (oldLinesIndex <= oldFoldedBlock.lastLine &&
+        newLinesIndex < newLines.length) {
+      if (newLines[newLinesIndex].text != oldLines[oldLinesIndex].text) {
+        return;
       }
+      newLinesIndex++;
+      oldLinesIndex++;
+    }
 
-      oldLineIndex++;
-      newLineIndex++;
+    final newBlockFirstLine = oldFoldedBlock.firstLine + lineCountDelta;
+    final newBlockOnSameLine = newFoldableBlocksMap[newBlockFirstLine];
+    if (newBlockOnSameLine?.lineCount != null &&
+        newBlockOnSameLine?.lineCount != oldFoldedBlock.lineCount) {
+      // If the new code has foldable block on the same line,
+      // that differs in lineCount with old folded block.
+      // E.g. add new import after folded imports block.
+      return;
     }
 
-    return true;
+    newFoldedBlocks.add(oldFoldedBlock.offset(lineCountDelta));
   }
 }
diff --git a/lib/src/gutter/gutter.dart b/lib/src/gutter/gutter.dart
index 684d9cfd..62d7630f 100644
--- a/lib/src/gutter/gutter.dart
+++ b/lib/src/gutter/gutter.dart
@@ -132,6 +132,21 @@ class GutterWidget extends StatelessWidget {
             : () => codeController.foldAt(block.firstLine),
       );
     }
+
+    // Add folded blocks that are not considered as a valid foldable block,
+    // but should be folded because they were folded before becoming invalid.
+    for (final block in code.foldedBlocks) {
+      final lineIndex = _lineIndexToTableRowIndex(block.firstLine);
+      if (lineIndex == null || lineIndex >= tableRows.length) {
+        continue;
+      }
+
+      tableRows[lineIndex].children![_foldingColumn] = FoldToggle(
+        color: style.textStyle?.color,
+        isFolded: true,
+        onTap: () => codeController.unfoldAt(block.firstLine),
+      );
+    }
   }
 
   int? _lineIndexToTableRowIndex(int line) {


Test patch:
diff --git a/test/src/code_field/code_controller_folding_editing_test.dart b/test/src/code_field/code_controller_folding_editing_test.dart
index 44298730..fba90bce 100644
--- a/test/src/code_field/code_controller_folding_editing_test.dart
+++ b/test/src/code_field/code_controller_folding_editing_test.dart
@@ -1,5 +1,7 @@
+import 'package:flutter/services.dart';
 import 'package:flutter/widgets.dart';
 import 'package:flutter_test/flutter_test.dart';
+import 'package:highlight/languages/python.dart';
 
 import '../common/create_app.dart';
 import '../common/snippets.dart';
@@ -17,17 +19,19 @@ private class MyClass {
 void main() {
   group('CodeController. Folding.', () {
     group('Editing', () {
-      testWidgets('above a folded block', (WidgetTester wt) async {
-        final controller = await pumpController(wt, TwoMethodsSnippet.full);
-        controller.foldAt(1);
-        await wt.selectFromHome(0, offset: 7);
-
-        controller.value = controller.value.replacedSelection('public');
-
-        expect(
-          controller.value,
-          const TextEditingValue(
-            text: '''
+      group('Folded block is still recognizable after edit', () {
+        group('Java', () {
+          testWidgets('above a folded block', (WidgetTester wt) async {
+            final controller = await pumpController(wt, TwoMethodsSnippet.full);
+            controller.foldAt(1);
+            await wt.selectFromHome(0, offset: 7);
+
+            controller.value = controller.value.replacedSelection('public');
+
+            expect(
+              controller.value,
+              const TextEditingValue(
+                text: '''
 public class MyClass {
   void method1() {
 
@@ -36,16 +40,16 @@ public class MyClass {
   }
 }
 ''',
-            selection: TextSelection(baseOffset: 0, extentOffset: 6),
-          ),
-        );
+                selection: TextSelection(baseOffset: 0, extentOffset: 6),
+              ),
+            );
 
-        controller.unfoldAt(1);
+            controller.unfoldAt(1);
 
-        expect(
-          controller.value,
-          const TextEditingValue(
-            text: '''
+            expect(
+              controller.value,
+              const TextEditingValue(
+                text: '''
 public class MyClass {
   void method1() {
     if (false) {
@@ -58,34 +62,35 @@ public class MyClass {
   }
 }
 ''',
-            selection: TextSelection(baseOffset: 0, extentOffset: 6),
-          ),
-        );
-      });
-
-      testWidgets('the first line of a folded block', (WidgetTester wt) async {
-        final controller = await pumpController(wt, TwoMethodsSnippet.full);
-        controller.foldAt(0);
-        await wt.selectFromHome(0, offset: 7);
-
-        controller.value = controller.value.replacedSelection('public');
-
-        expect(
-          controller.value,
-          const TextEditingValue(
-            text: '''
+                selection: TextSelection(baseOffset: 0, extentOffset: 6),
+              ),
+            );
+          });
+
+          testWidgets('the first line of a folded block',
+              (WidgetTester wt) async {
+            final controller = await pumpController(wt, TwoMethodsSnippet.full);
+            controller.foldAt(0);
+            await wt.selectFromHome(0, offset: 7);
+
+            controller.value = controller.value.replacedSelection('public');
+
+            expect(
+              controller.value,
+              const TextEditingValue(
+                text: '''
 public class MyClass {
 ''',
-            selection: TextSelection(baseOffset: 0, extentOffset: 6),
-          ),
-        );
+                selection: TextSelection(baseOffset: 0, extentOffset: 6),
+              ),
+            );
 
-        controller.unfoldAt(0);
+            controller.unfoldAt(0);
 
-        expect(
-          controller.value,
-          const TextEditingValue(
-            text: '''
+            expect(
+              controller.value,
+              const TextEditingValue(
+                text: '''
 public class MyClass {
   void method1() {
     if (false) {
@@ -98,23 +103,23 @@ public class MyClass {
   }
 }
 ''',
-            selection: TextSelection(baseOffset: 0, extentOffset: 6),
-          ),
-        );
-      });
-
-      testWidgets('between folded blocks', (WidgetTester wt) async {
-        final controller = await pumpController(wt, TwoMethodsSnippet.full);
-        controller.foldAt(1);
-        controller.foldAt(7);
-
-        await wt.selectFromHome(43);
-        controller.value = controller.value.replacedSelection('int n;\n');
-
-        expect(
-          controller.value,
-          const TextEditingValue(
-            text: '''
+                selection: TextSelection(baseOffset: 0, extentOffset: 6),
+              ),
+            );
+          });
+
+          testWidgets('between folded blocks', (WidgetTester wt) async {
+            final controller = await pumpController(wt, TwoMethodsSnippet.full);
+            controller.foldAt(1);
+            controller.foldAt(7);
+
+            await wt.selectFromHome(43);
+            controller.value = controller.value.replacedSelection('int n;\n');
+
+            expect(
+              controller.value,
+              const TextEditingValue(
+                text: '''
 private class MyClass {
   void method1() {
 int n;
@@ -122,17 +127,17 @@ int n;
   void method2() {
 }
 ''',
-            selection: TextSelection.collapsed(offset: 50),
-          ),
-        );
+                selection: TextSelection.collapsed(offset: 50),
+              ),
+            );
 
-        controller.unfoldAt(1);
-        controller.unfoldAt(8);
+            controller.unfoldAt(1);
+            controller.unfoldAt(8);
 
-        expect(
-          controller.value,
-          const TextEditingValue(
-            text: '''
+            expect(
+              controller.value,
+              const TextEditingValue(
+                text: '''
 private class MyClass {
   void method1() {
     if (false) {
@@ -146,9 +151,366 @@ int n;
   }
 }
 ''',
-            selection: TextSelection.collapsed(offset: 91),
-          ),
-        );
+                selection: TextSelection.collapsed(offset: 91),
+              ),
+            );
+          });
+
+          testWidgets('Add multiline text before and after a folded block',
+              (wt) async {
+            const visibleText = '\nmethod1(){\n';
+            const example = '\nmethod1(){\n  if (true) return;\n}\n';
+            const insertedText = 'a\na\na\n';
+
+            final controller = await pumpController(wt, example);
+
+            controller.foldAt(1);
+
+            await wt.cursorHome();
+
+            controller.value = controller.value.replacedSelection(insertedText);
+
+            const insertedBeforeExpected = TextEditingValue(
+              text: insertedText + visibleText,
+              selection: TextSelection.collapsed(offset: 6),
+            );
+
+            expect(controller.value, insertedBeforeExpected);
+
+            await wt.cursorEnd();
+            controller.value = controller.value.replacedSelection(insertedText);
+
+            const insertedAfterExpected = TextEditingValue(
+              text: insertedText + visibleText + insertedText,
+              selection: TextSelection.collapsed(offset: 24),
+            );
+
+            expect(controller.value, insertedAfterExpected);
+          });
+
+          testWidgets('Remove multiline text before folded block', (wt) async {
+            const example = 'a\na\na\nmethod1(){\n  if (true) return;\n}\n';
+
+            final controller = await pumpController(wt, example);
+            controller.foldAt(3);
+            await wt.selectFromHome(0, offset: 6);
+
+            const expected = TextEditingValue(
+              text: 'method1(){\n',
+              selection: TextSelection.collapsed(offset: 0),
+            );
+
+            controller.value = controller.value.replacedSelection('');
+            expect(controller.value, expected);
+          });
+
+          testWidgets('Adding an import after folded imports -> Unfold',
+              (wt) async {
+            const example = 'package example;\nimport java.util.Date;\n';
+            const inserted = 'import java.util.ArrayList;';
+            final controller = await pumpController(wt, example);
+
+            controller.foldImports();
+            expect(controller.code.foldedBlocks.length, 1);
+
+            await wt.cursorEnd();
+            controller.value = controller.value.replacedSelection(inserted);
+            // TODO(yescorp): Fix selection, then fix tests
+            //  https://github.com/akvelon/flutter-code-editor/issues/182
+            expect(controller.value.text, example + inserted);
+            expect(controller.code.foldedBlocks.length, 0);
+          });
+
+          testWidgets(
+              'Adding non-import after folded imports -> Does not unfold',
+              (wt) async {
+            const example = 'package example;\nimport java.util.Date;\n';
+            const visible = 'package example;\n';
+            const inserted = 'a';
+            final controller = await pumpController(wt, example);
+
+            controller.foldImports();
+            expect(controller.code.foldedBlocks.length, 1);
+
+            await wt.cursorEnd();
+            controller.value = controller.value.replacedSelection(inserted);
+
+            expect(controller.value.text, visible + inserted);
+            expect(controller.code.foldedBlocks.length, 1);
+            await wt.moveCursor(-1); // Reset timer.
+          });
+
+          testWidgets('Add comment after folded comments block -> Unfold',
+              (wt) async {
+            const example = '// comment 1\n// comment 2\n';
+            const inserted = '// comment 3';
+            final controller = await pumpController(wt, example);
+
+            controller.foldAt(0);
+            expect(controller.code.foldedBlocks.length, 1);
+
+            await wt.cursorEnd();
+            controller.value = controller.value.replacedSelection(inserted);
+            // TODO(yescorp): Fix selection, then fix tests
+            //  https://github.com/akvelon/flutter-code-editor/issues/182
+            expect(controller.value.text, example + inserted);
+            expect(controller.code.foldedBlocks.length, 0);
+          });
+
+          testWidgets(
+              'Add non-comment after folded comments -> Does not unfold',
+              (wt) async {
+            const example = '// comment 1\n// comment 2\n';
+            const visible = '// comment 1\n';
+            const inserted = 'a';
+            final controller = await pumpController(wt, example);
+
+            controller.foldAt(0);
+            expect(controller.code.foldedBlocks.length, 1);
+
+            await wt.cursorEnd();
+            controller.value = controller.value.replacedSelection(inserted);
+            expect(controller.value.text, visible + inserted);
+            expect(controller.code.foldedBlocks.length, 1);
+
+            await wt.moveCursor(-1); // Reset timer.
+          });
+        });
+
+        group('Python', () {
+          testWidgets(
+              'Indent block is opened '
+              'if the next line is being edited to be a part of this block',
+              (wt) async {
+            const example = 'a:\n  aaaa\n';
+            final controller = await pumpController(
+              wt,
+              example,
+              language: python,
+            );
+            controller.foldAt(0);
+
+            expect(controller.value.text, 'a:\n');
+            //                                 \ selection
+            await wt.selectFromHome(3);
+            controller.value = controller.value.replacedSelection('  bbbb');
+
+            const expectedText = 'a:\n  aaaa\n  bbbb';
+
+            expect(controller.value.text, expectedText);
+            expect(controller.code.foldedBlocks.length, 0);
+          });
+
+          testWidgets(
+              'Indent block is not opened if the next line is edited '
+              'but it is not part of the block yet', (wt) async {
+            const example = 'a:\n  aaaa\n';
+            final controller = await pumpController(
+              wt,
+              example,
+              language: python,
+            );
+            controller.foldAt(0);
+
+            expect(controller.value.text, 'a:\n');
+            //                                 \ selection
+            await wt.selectFromHome(3);
+            controller.value = controller.value.replacedSelection('bbbb');
+
+            const expectedText = 'a:\nbbbb';
+
+            expect(controller.value.text, expectedText);
+            expect(controller.code.foldedBlocks.length, 1);
+
+            controller.unfoldAt(0);
+
+            expect(controller.value.text, 'a:\n  aaaa\nbbbb');
+            expect(controller.code.foldedBlocks.length, 0);
+          });
+
+          testWidgets(
+              'Indent block is not opened '
+              'if the newline char was inserted to the next line', (wt) async {
+            const example = 'a:\n  aaaa\n\n';
+            final controller = await pumpController(
+              wt,
+              example,
+              language: python,
+            );
+
+            controller.foldAt(0);
+
+            expect(controller.value.text, 'a:\n\n');
+            //                                 \ selection
+            await wt.selectFromHome(3);
+
+            controller.value = controller.value.replacedSelection('\n');
+
+            const expectedText = 'a:\n\n\n';
+
+            expect(controller.value.text, expectedText);
+            expect(controller.code.foldedBlocks.length, 1);
+            await wt.moveCursor(-1); // Reset timer.
+          });
+        });
+      });
+
+      group('Folded block is no longer recognizable', () {
+        group('Miltiline comment that started before folded block', () {
+          testWidgets('Does not unfold the block', (wt) async {
+            const example = '\na{\n}';
+            //               \ starting selection
+            final controller = await pumpController(wt, example);
+            controller.foldAt(1);
+
+            await wt.selectFromHome(0);
+            controller.value = controller.value.replacedSelection('/*');
+            const expected = TextEditingValue(
+              text: '/*\na{',
+              //       \ selection after insertion
+              selection: TextSelection.collapsed(offset: 2),
+            );
+            expect(controller.value, expected);
+          });
+
+          testWidgets('The block is still unfoldable manually', (wt) async {
+            const example = '\na{\n}';
+            //               \ starting selection
+            final controller = await pumpController(wt, example);
+            controller.foldAt(1);
+
+            await wt.selectFromHome(0);
+            controller.value = controller.value.replacedSelection('/*');
+            const expected = TextEditingValue(
+              text: '/*\na{',
+              //       \ selection after insertion
+              selection: TextSelection.collapsed(offset: 2),
+            );
+            expect(controller.value, expected);
+
+            controller.unfoldAt(1);
+            const unfoldedResult = TextEditingValue(
+              text: '/*\na{\n}',
+              //       \ selection
+              selection: TextSelection.collapsed(offset: 2),
+            );
+            expect(controller.value, unfoldedResult);
+            expect(controller.code.foldedBlocks.length, 0);
+          });
+
+          testWidgets('The block is deletable', (wt) async {
+            const example = '\na{\n}\n';
+            //               \ starting selection
+            final controller = await pumpController(wt, example);
+            controller.foldAt(1);
+
+            await wt.selectFromHome(0);
+            controller.value = controller.value.replacedSelection('/*');
+            const expected = TextEditingValue(
+              text: '/*\na{\n',
+              //       \ selection after insertion
+              selection: TextSelection.collapsed(offset: 2),
+            );
+            expect(controller.value, expected);
+
+            // select everything
+            await wt.selectFromHome(
+              2,
+              offset: controller.value.text.length - 2,
+            );
+            controller.value = controller.value.replacedSelection('');
+            const deletedResult = TextEditingValue(
+              text: '/*',
+              selection: TextSelection.collapsed(offset: 2),
+            );
+            expect(controller.value, deletedResult);
+            expect(controller.code.foldedBlocks.length, 0);
+          });
+        });
+
+        group('Multiline String', () {
+          testWidgets('Does not unfold the block', (wt) async {
+            const example = '\na{\n}';
+            //               \ starting selection
+            final controller = await pumpController(
+              wt,
+              example,
+              language: python,
+            );
+            controller.foldAt(1);
+
+            await wt.selectFromHome(0);
+            controller.value = controller.value.replacedSelection('"""');
+            const expected = TextEditingValue(
+              text: '"""\na{',
+              //       \ selection after insertion
+              selection: TextSelection.collapsed(offset: 3),
+            );
+            expect(controller.value, expected);
+          });
+
+          testWidgets('The block is still unfoldable manually', (wt) async {
+            const example = '\na{\n}';
+            //               \ starting selection
+            final controller = await pumpController(
+              wt,
+              example,
+              language: python,
+            );
+            controller.foldAt(1);
+
+            await wt.selectFromHome(0);
+            controller.value = controller.value.replacedSelection('"""');
+            const expected = TextEditingValue(
+              text: '"""\na{',
+              //       \ selection after insertion
+              selection: TextSelection.collapsed(offset: 3),
+            );
+            expect(controller.value, expected);
+
+            controller.unfoldAt(1);
+            const unfoldedResult = TextEditingValue(
+              text: '"""\na{\n}',
+              //       \ selection
+              selection: TextSelection.collapsed(offset: 3),
+            );
+            expect(controller.value, unfoldedResult);
+            expect(controller.code.foldedBlocks.length, 0);
+          });
+
+          testWidgets('The block is deletable', (wt) async {
+            const example = '\na{\n}\n';
+            //               \ starting selection
+            final controller = await pumpController(
+              wt,
+              example,
+              language: python,
+            );
+            controller.foldAt(1);
+
+            await wt.selectFromHome(0);
+            controller.value = controller.value.replacedSelection('"""');
+            const expected = TextEditingValue(
+              text: '"""\na{\n',
+              //       \ selection after insertion
+              selection: TextSelection.collapsed(offset: 3),
+            );
+            expect(controller.value, expected);
+
+            // select everything
+            await wt.selectFromHome(
+              3,
+              offset: controller.value.text.length - 3,
+            );
+            controller.value = controller.value.replacedSelection('');
+            const deletedResult = TextEditingValue(
+              text: '"""',
+              selection: TextSelection.collapsed(offset: 3),
+            );
+            expect(controller.value, deletedResult);
+            expect(controller.code.foldedBlocks.length, 0);
+          });
+        });
       });
     });
 
diff --git a/test/src/common/create_app.dart b/test/src/common/create_app.dart
index 3984bfa1..60247a6c 100644
--- a/test/src/common/create_app.dart
+++ b/test/src/common/create_app.dart
@@ -16,8 +16,12 @@ MaterialApp createApp(CodeController controller, FocusNode focusNode) {
   );
 }
 
-Future<CodeController> pumpController(WidgetTester wt, String text) async {
-  final controller = createController(text);
+Future<CodeController> pumpController(
+  WidgetTester wt,
+  String text, {
+  Mode? language,
+}) async {
+  final controller = createController(text, language: language);
 
   focusNode = FocusNode();
   await wt.pumpWidget(createApp(controller, focusNode));
diff --git a/test/src/folding/foldable_block_test.dart b/test/src/folding/foldable_block_test.dart
index 3403891f..5ae6ffa4 100644
--- a/test/src/folding/foldable_block_test.dart
+++ b/test/src/folding/foldable_block_test.dart
@@ -141,4 +141,29 @@ void main() {
       expect(actual, expected);
     });
   });
+
+  test('Offset method', () {
+    const block = FB(firstLine: 5, lastLine: 7, type: FBT.braces);
+
+    final positiveOffset = block.offset(3);
+    final negativeOffset = block.offset(-4);
+
+    expect(
+      positiveOffset,
+      const FB(firstLine: 5 + 3, lastLine: 7 + 3, type: FBT.braces),
+    );
+
+    expect(
+      negativeOffset,
+      const FB(firstLine: 5 - 4, lastLine: 7 - 4, type: FBT.braces),
+    );
+  });
+
+  test('line count getter test', () {
+    const block = FB(firstLine: 5, lastLine: 7, type: FBT.braces);
+
+    final lineCount = block.lineCount;
+
+    expect(lineCount, 7 - (5 - 1));
+  });
 }