Repository: akvelon/flutter-code-editor
Pull request #249 (2023-06-16 10:57:57)

Issue248 backspace

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/akvelon/flutter-code-editor/pull/249?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akvelon) Report
> Merging [#249](https://app.codecov.io/gh/akvelon/flutter-code-editor/pull/249?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akvelon) (8123818) into [main](https://app.codecov.io/gh/akvelon/flutter-code-editor/commit/51dbeeabf8017840e1e5b460f8340c5ac6139b34?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akvelon) (51dbeea) will **increase** coverage by `0.10%`.
> The diff coverage is `97.05%`.

```diff
@@            Coverage Diff             @@
##             main     #249      +/-   ##
==========================================
+ Coverage   82.50%   82.61%   +0.10%     
==========================================
  Files         107      107              
  Lines        3144     3163      +19     
==========================================
+ Hits         2594     2613      +19     
  Misses        550      550              
```


| [Impacted Files](https://app.codecov.io/gh/akvelon/flutter-code-editor/pull/249?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akvelon) | Coverage Δ | |
|---|---|---|
| [lib/src/code/string.dart](https://app.codecov.io/gh/akvelon/flutter-code-editor/pull/249?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akvelon#diff-bGliL3NyYy9jb2RlL3N0cmluZy5kYXJ0) | `100.00% <ø> (ø)` | |
| [.../code\_field/search\_result\_highlighted\_builder.dart](https://app.codecov.io/gh/akvelon/flutter-code-editor/pull/249?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akvelon#diff-bGliL3NyYy9jb2RlX2ZpZWxkL3NlYXJjaF9yZXN1bHRfaGlnaGxpZ2h0ZWRfYnVpbGRlci5kYXJ0) | `96.15% <ø> (ø)` | |
| [lib/src/code/code.dart](https://app.codecov.io/gh/akvelon/flutter-code-editor/pull/249?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akvelon#diff-bGliL3NyYy9jb2RlL2NvZGUuZGFydA==) | `90.85% <50.00%> (ø)` | |
| [lib/src/code\_field/text\_editing\_value.dart](https://app.codecov.io/gh/akvelon/flutter-code-editor/pull/249?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akvelon#diff-bGliL3NyYy9jb2RlX2ZpZWxkL3RleHRfZWRpdGluZ192YWx1ZS5kYXJ0) | `99.04% <100.00%> (+0.36%)` | :arrow_up: |
| [lib/src/search/search\_navigation\_controller.dart](https://app.codecov.io/gh/akvelon/flutter-code-editor/pull/249?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akvelon#diff-bGliL3NyYy9zZWFyY2gvc2VhcmNoX25hdmlnYXRpb25fY29udHJvbGxlci5kYXJ0) | `96.87% <100.00%> (ø)` | |



Patch:
diff --git a/lib/src/code/code.dart b/lib/src/code/code.dart
index 85ddc83..4241e70 100644
--- a/lib/src/code/code.dart
+++ b/lib/src/code/code.dart
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
 import 'package:highlight/highlight_core.dart';
 
 import '../../src/highlight/result.dart';
+import '../code_field/text_editing_value.dart';
 import '../folding/foldable_block.dart';
 import '../folding/foldable_block_matcher.dart';
 import '../folding/invalid_foldable_block.dart';
@@ -325,7 +326,7 @@ class Code {
     TextSelection oldSelection,
     TextEditingValue visibleAfter,
   ) {
-    final visibleRangeAfter = visibleAfter.text.getChangedRangeAroundSelection(
+    final visibleRangeAfter = visibleAfter.getChangedRange(
           TextEditingValue(text: visibleText, selection: oldSelection),
         ) ??
         visibleAfter.text.getChangedRange(
@@ -385,7 +386,7 @@ class Code {
         foldedBlocks.any(
           (block) =>
               block.lastLine >= firstChangedLine &&
-              block.lastLine <= lastChangedLine,
+              block.lastLine < lastChangedLine,
         )) {
       return CodeEditResult(
         fullTextAfter: text,
diff --git a/lib/src/code/string.dart b/lib/src/code/string.dart
index 4b4edf6..fbab3d4 100644
--- a/lib/src/code/string.dart
+++ b/lib/src/code/string.dart
@@ -5,41 +5,6 @@ import 'package:flutter/widgets.dart';
 import '../code_field/text_editing_value.dart';
 
 extension StringExtension on String {
-  /// Returns a [TextRange] round selection in [oldValue]
-  /// that is different from this.
-  ///
-  /// This is useful if the ordinary diff with [getChangedRange]
-  /// may be ambiguous. This method tests for the most common changes
-  /// and snaps the changed range to the old selection.
-  ///
-  /// Handles these special cases:
-  /// - Selected text is replaced / deleted / inserted, outside is unchanged.
-  /// - Insertion at a collapsed selection.
-  ///
-  /// Returns null if neither of the above cases apply.
-  TextRange? getChangedRangeAroundSelection(TextEditingValue oldValue) {
-    final oldBefore = oldValue.beforeSelection;
-    final oldAfter = oldValue.afterSelection;
-
-    if (length < oldBefore.length + oldAfter.length) {
-      // The outside of the selection has shortened.
-      // definitely not an above special case.
-      return null;
-    }
-
-    final newBefore = substring(0, oldBefore.length);
-    final newAfter = substring(length - oldAfter.length);
-
-    if (oldBefore == newBefore && oldAfter == newAfter) {
-      return TextRange(
-        start: newBefore.length,
-        end: length - newAfter.length,
-      );
-    }
-
-    return null;
-  }
-
   /// Returns the widest [TextRange] of this that is different from [old].
   ///
   /// `start` refers to the common prefix. It is the first character of this
@@ -55,7 +20,8 @@ extension StringExtension on String {
   ///  - Inserting a duplicate: aBc -> aBBc
   ///  - Deletion of a duplicate: aBBc -> aBc
   ///
-  /// This method should be used if [getChangedRangeAroundSelection] failed.
+  /// This method should be used
+  /// if [TextEditingValueExtension.getChangedRange] failed.
   TextRange getChangedRange(
     String old, {
     required TextAffinity attributeChangeTo,
diff --git a/lib/src/code_field/text_editing_value.dart b/lib/src/code_field/text_editing_value.dart
index 3c6ef39..7af04e7 100644
--- a/lib/src/code_field/text_editing_value.dart
+++ b/lib/src/code_field/text_editing_value.dart
@@ -3,6 +3,8 @@ import 'package:flutter/widgets.dart';
 import '../code/reg_exp.dart';
 import '../code/string.dart';
 import '../code/text_range.dart';
+import '../code_field/code_controller.dart';
+import '../util/edit_type.dart';
 import 'text_selection.dart';
 
 extension TextEditingValueExtension on TextEditingValue {
@@ -185,4 +187,73 @@ extension TextEditingValueExtension on TextEditingValue {
       text: text,
     );
   }
+
+  /// Returns the widest [TextRange] of this that is different from [oldValue]
+  /// if it can be produced by any of common edits allowed for user input.
+  /// These are all edits that go through [CodeController.value] setter
+  /// and do not include undo/redo.
+  ///
+  /// Returns null if the change cannot be produced by such user edits.
+  TextRange? getChangedRange(TextEditingValue oldValue) {
+    switch (getEditType(oldValue)) {
+      case EditType.backspaceBeforeCollapsedSelection:
+        return TextRange.collapsed(
+          text.length - oldValue.afterSelection.length,
+        );
+
+      case EditType.deleteSelection:
+      case EditType.deleteAfterCollapsedSelection:
+        return TextRange.collapsed(oldValue.beforeSelection.length);
+
+      case EditType.replaceSelection:
+      case EditType.insertAtCollapsedSelection:
+        return TextRange(
+          start: oldValue.beforeSelection.length,
+          end: text.length - oldValue.afterSelection.length,
+        );
+
+      case EditType.unchanged:
+      case EditType.other:
+        return null;
+    }
+  }
+
+  EditType getEditType(TextEditingValue oldValue) {
+    if (oldValue.text == text) {
+      return EditType.unchanged;
+    }
+
+    final oldBefore = oldValue.beforeSelection;
+    final oldAfter = oldValue.afterSelection;
+    final oldUnselectedLength = oldBefore.length + oldAfter.length;
+
+    if (text.length < oldUnselectedLength) {
+      if (text.startsWith(oldBefore) && selection == oldValue.selection) {
+        return EditType.deleteAfterCollapsedSelection;
+      }
+
+      if (text.endsWith(oldAfter) &&
+          selection.isCollapsed &&
+          selection.start ==
+              text.length - oldValue.text.length + oldValue.selection.start) {
+        return EditType.backspaceBeforeCollapsedSelection;
+      }
+
+      return EditType.other;
+    }
+
+    if (text.startsWith(oldBefore) && text.endsWith(oldAfter)) {
+      if (oldValue.selection.isCollapsed) {
+        return EditType.insertAtCollapsedSelection;
+      }
+
+      if (text.length == oldUnselectedLength) {
+        return EditType.deleteSelection;
+      }
+
+      return EditType.replaceSelection;
+    }
+
+    return EditType.other;
+  }
 }
diff --git a/lib/src/util/edit_type.dart b/lib/src/util/edit_type.dart
new file mode 100644
index 0000000..391d448
--- /dev/null
+++ b/lib/src/util/edit_type.dart
@@ -0,0 +1,12 @@
+enum EditType {
+  backspaceBeforeCollapsedSelection,
+  deleteAfterCollapsedSelection,
+  deleteSelection,
+  insertAtCollapsedSelection,
+  replaceSelection,
+  unchanged,
+
+  /// A change beyond a user's ability to interact with the editor
+  /// like replacing an unselected text in one action.
+  other,
+}


Test patch:
diff --git a/test/src/code/code_get_edit_result_test.dart b/test/src/code/code_get_edit_result_test.dart
index d4b3365..997af13 100644
--- a/test/src/code/code_get_edit_result_test.dart
+++ b/test/src/code/code_get_edit_result_test.dart
@@ -42,6 +42,7 @@ void main() {
         'Empty -> Something',
         fullTextBefore: '',
         visibleValueAfter: TextEditingValue(text: _visibleText1),
+        visibleSelectionBefore: TextSelection.collapsed(offset: 0),
         expected: CodeEditResult(
           fullTextAfter: _visibleText1,
           linesChanged: TextRange(start: 0, end: 0),
@@ -52,6 +53,7 @@ void main() {
         'Something -> Empty',
         fullTextBefore: _fullText1,
         visibleValueAfter: TextEditingValue.empty,
+        visibleSelectionBefore: TextSelection.collapsed(offset: 0), // any
         expected: CodeEditResult(
           fullTextAfter: '',
           linesChanged: TextRange(start: 0, end: 8), // Empty line 9 is intact.
@@ -61,6 +63,7 @@ void main() {
       _Example(
         'Change not touching hidden range borders',
         fullTextBefore: _fullText1,
+        visibleSelectionBefore: TextSelection.collapsed(offset: 64),
         visibleValueAfter: TextEditingValue(
           // Each blank line has two spaces here:
           text: '''
@@ -69,11 +72,12 @@ public class MyClass {
   }
   
   
-  method(int a) {{
-  }}
+  voidmethod(int a) {
+  }
   
 }
 ''',
+          selection: TextSelection.collapsed(offset: 63),
         ),
         expected: CodeEditResult(
           fullTextAfter: '''
@@ -82,18 +86,19 @@ public class MyClass {
   }
   // [END section1]
   // [START section2]
-  method(int a) {{
-  }}
+  voidmethod(int a) {
+  }
   // [END section2]
 }
 ''',
-          linesChanged: TextRange(start: 5, end: 6),
+          linesChanged: TextRange(start: 5, end: 5),
         ),
       ),
 
       _Example(
         'Insertion at a range collapse - Inserts before the range',
         fullTextBefore: _fullText1,
+        visibleSelectionBefore: TextSelection.collapsed(offset: 81),
         visibleValueAfter: TextEditingValue(
           // Each blank line has two spaces here:
           text: '''
@@ -107,6 +112,7 @@ public class MyClass {
   ;
 }
 ''',
+          selection: TextSelection.collapsed(offset: 82),
         ),
         expected: CodeEditResult(
           fullTextAfter: '''
@@ -125,18 +131,21 @@ public class MyClass {
       ),
 
       _Example(
-        'Removing a block that is both before and after a hidden range - '
+        'Backspace on a block that is both before and after a hidden range - '
         'Removes it before',
+        // block == '\n'
         fullTextBefore: '''
 {
 //[START section1]
 }
 ''',
+        visibleSelectionBefore: TextSelection.collapsed(offset: 2),
         visibleValueAfter: TextEditingValue(
           text: '''
 {
 }
 ''',
+          selection: TextSelection.collapsed(offset: 1),
         ),
         expected: CodeEditResult(
           fullTextAfter: '''
@@ -148,13 +157,40 @@ public class MyClass {
       ),
 
       _Example(
-        'Replacing between ranges keeps the ranges - '
+        'Delete on a block that is both before and after a hidden range - '
+        'Removes it before',
+        // block == '\n'
+        fullTextBefore: '''
+{
+//[START section1]
+}
+''',
+        visibleSelectionBefore: TextSelection.collapsed(offset: 1),
+        visibleValueAfter: TextEditingValue(
+          text: '''
+{
+}
+''',
+          selection: TextSelection.collapsed(offset: 1),
+        ),
+        expected: CodeEditResult(
+          fullTextAfter: '''
+{//[START section1]
+}
+''',
+          linesChanged: TextRange(start: 0, end: 1),
+        ),
+      ),
+
+      _Example(
+        'Replacing between ranges - '
         'Keeps the range after, Deletes the range before',
         fullTextBefore: '''
 {//[START section1]
 ;//[END section1]
 }
 ''',
+        visibleSelectionBefore: TextSelection(baseOffset: 1, extentOffset: 3),
         visibleValueAfter: TextEditingValue(
           text: '''
 {()
@@ -173,8 +209,10 @@ public class MyClass {
       _Example(
         'If all text is a single hidden range, insert before it',
         fullTextBefore: '//[START section1]',
+        visibleSelectionBefore: TextSelection.collapsed(offset: 0),
         visibleValueAfter: TextEditingValue(
           text: ';',
+          selection: TextSelection.collapsed(offset: 1),
         ),
         expected: CodeEditResult(
           fullTextAfter: ';//[START section1]',
@@ -196,25 +234,24 @@ public class MyClass {
         namedSectionParser: const BracketsStartEndNamedSectionParser(),
       );
 
-      final selections = [
-        for (int n = code.visibleText.length; --n >= -1;)
-          TextSelection.collapsed(offset: n),
-      ];
-
-      for (final selection in selections) {
-        expect(
-          () => code.getEditResult(selection, example.visibleValueAfter),
-          returnsNormally,
-          reason: example.name,
-        );
-
-        final result = code.getEditResult(selection, example.visibleValueAfter);
-        expect(
-          result,
-          example.expected,
-          reason: example.name,
-        );
-      }
+      expect(
+        () => code.getEditResult(
+          example.visibleSelectionBefore,
+          example.visibleValueAfter,
+        ),
+        returnsNormally,
+        reason: example.name,
+      );
+
+      final result = code.getEditResult(
+        example.visibleSelectionBefore,
+        example.visibleValueAfter,
+      );
+      expect(
+        result,
+        example.expected,
+        reason: example.name,
+      );
     }
   });
 }
@@ -222,12 +259,14 @@ public class MyClass {
 class _Example {
   final String name;
   final String fullTextBefore;
+  final TextSelection visibleSelectionBefore;
   final TextEditingValue visibleValueAfter;
   final CodeEditResult? expected;
 
   const _Example(
     this.name, {
     required this.fullTextBefore,
+    required this.visibleSelectionBefore,
     required this.visibleValueAfter,
     this.expected,
   });
diff --git a/test/src/code/string_get_changed_range_around_selection_test.dart b/test/src/code_field/text_editing_value_get_changed_range_test.dart
similarity index 51%
rename from test/src/code/string_get_changed_range_around_selection_test.dart
rename to test/src/code_field/text_editing_value_get_changed_range_test.dart
index 146b183..b7ad632 100644
--- a/test/src/code/string_get_changed_range_around_selection_test.dart
+++ b/test/src/code_field/text_editing_value_get_changed_range_test.dart
@@ -1,10 +1,10 @@
 import 'package:flutter/widgets.dart';
-import 'package:flutter_code_editor/src/code/string.dart';
+import 'package:flutter_code_editor/src/code_field/text_editing_value.dart';
 import 'package:flutter_code_editor/src/code_field/text_selection.dart';
 import 'package:flutter_test/flutter_test.dart';
 
 void main() {
-  test('String.getChangedRangeAroundSelection', () {
+  test('TextEditingValue.getChangedRange', () {
     const examples = [
       //
       _Example(
@@ -14,7 +14,11 @@ void main() {
           //        <->
           selection: TextSelection(baseOffset: 3, extentOffset: 6),
         ),
-        newString: 'abcghi',
+        newValue: TextEditingValue(
+          text: 'abcghi',
+          //        \ cursor
+          selection: TextSelection.collapsed(offset: 3),
+        ),
         expected: TextRange(start: 3, end: 3),
       ),
 
@@ -25,7 +29,11 @@ void main() {
           //        <->
           selection: TextSelection(baseOffset: 3, extentOffset: 6),
         ),
-        newString: 'abcdeghi',
+        newValue: TextEditingValue(
+          text: 'abcdeghi',
+          //         \ cursor
+          selection: TextSelection.collapsed(offset: 4),
+        ),
         expected: TextRange(start: 3, end: 5),
       ),
 
@@ -36,8 +44,11 @@ void main() {
           //        \ cursor
           selection: TextSelection.collapsed(offset: 3),
         ),
-        newString: 'abcDDghi',
-        //             +
+        newValue: TextEditingValue(
+          text: 'abcDDghi',
+          //        +\ cursor
+          selection: TextSelection.collapsed(offset: 4),
+        ),
         expected: TextRange(start: 3, end: 4),
       ),
 
@@ -48,31 +59,71 @@ void main() {
           //         \ cursor
           selection: TextSelection.collapsed(offset: 4),
         ),
-        newString: 'abcDDghi',
-        //              +
+        newValue: TextEditingValue(
+          text: 'abcDDghi',
+          //         +\ cursor
+          selection: TextSelection.collapsed(offset: 5),
+        ),
         expected: TextRange(start: 4, end: 5),
       ),
 
       _Example(
-        'Delete',
+        'Delete disambiguated first b',
         oldValue: TextEditingValue(
+          text: 'abbc',
+          //      \ cursor
+          selection: TextSelection.collapsed(offset: 1),
+        ),
+        newValue: TextEditingValue(
           text: 'abc',
           //      \ cursor
           selection: TextSelection.collapsed(offset: 1),
         ),
-        newString: 'ac',
-        expected: null,
+        expected: TextRange(start: 1, end: 1),
       ),
 
       _Example(
-        'Backspace',
+        'Delete disambiguated second b',
         oldValue: TextEditingValue(
+          text: 'abbc',
+          //       \ cursor
+          selection: TextSelection.collapsed(offset: 2),
+        ),
+        newValue: TextEditingValue(
           text: 'abc',
           //       \ cursor
           selection: TextSelection.collapsed(offset: 2),
         ),
-        newString: 'ac',
-        expected: null,
+        expected: TextRange(start: 2, end: 2),
+      ),
+
+      _Example(
+        'Backspace disambiguated first b',
+        oldValue: TextEditingValue(
+          text: 'abbc',
+          //       \ cursor
+          selection: TextSelection.collapsed(offset: 2),
+        ),
+        newValue: TextEditingValue(
+          text: 'abc',
+          //      \ cursor
+          selection: TextSelection.collapsed(offset: 1),
+        ),
+        expected: TextRange(start: 1, end: 1),
+      ),
+
+      _Example(
+        'Backspace disambiguated second b',
+        oldValue: TextEditingValue(
+          text: 'abbc',
+          //        \ cursor
+          selection: TextSelection.collapsed(offset: 3),
+        ),
+        newValue: TextEditingValue(
+          text: 'abc',
+          selection: TextSelection.collapsed(offset: 2),
+        ),
+        expected: TextRange(start: 2, end: 2),
       ),
 
       _Example(
@@ -82,7 +133,11 @@ void main() {
           //      \ cursor
           selection: TextSelection.collapsed(offset: 1),
         ),
-        newString: 'Abc',
+        newValue: TextEditingValue(
+          text: 'Abc',
+          //      \ cursor
+          selection: TextSelection.collapsed(offset: 1), // any
+        ),
         expected: null,
       ),
 
@@ -93,23 +148,26 @@ void main() {
           //      \ cursor
           selection: TextSelection.collapsed(offset: 1),
         ),
-        newString: 'abC',
+        newValue: TextEditingValue(
+          text: 'abC',
+          //      \ cursor
+          selection: TextSelection.collapsed(offset: 1), // any
+        ),
         expected: null,
       ),
     ];
 
     for (final example in examples) {
       expect(
-        () =>
-            example.newString.getChangedRangeAroundSelection(example.oldValue),
+        () => example.newValue.getChangedRange(example.oldValue),
         returnsNormally,
         reason: example.name,
       );
 
-      final range = example.newString.getChangedRangeAroundSelection(
+      final range = example.newValue.getChangedRange(
         example.oldValue,
       );
-      final reversedRange = example.newString.getChangedRangeAroundSelection(
+      final reversedRange = example.newValue.getChangedRange(
         example.oldValue.copyWith(
           selection: example.oldValue.selection.reversed,
         ),
@@ -132,13 +190,13 @@ void main() {
 class _Example {
   final String name;
   final TextEditingValue oldValue;
-  final String newString;
+  final TextEditingValue newValue;
   final TextRange? expected;
 
   const _Example(
     this.name, {
     required this.oldValue,
-    required this.newString,
+    required this.newValue,
     required this.expected,
   });
 }
diff --git a/test/src/code_field/text_editing_value_get_edit_type.dart b/test/src/code_field/text_editing_value_get_edit_type.dart
new file mode 100644
index 0000000..d3cb411
--- /dev/null
+++ b/test/src/code_field/text_editing_value_get_edit_type.dart
@@ -0,0 +1,223 @@
+import 'package:flutter/services.dart';
+import 'package:flutter_code_editor/src/code_field/text_editing_value.dart';
+import 'package:flutter_code_editor/src/util/edit_type.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  test('TextEditingValue.getEditType', () {
+    const examples = [
+      //
+      _Example(
+        'No change, collapsed',
+        oldValue: TextEditingValue(
+          text: 'abc',
+          //       \ cursor
+          selection: TextSelection.collapsed(offset: 2), // any
+        ),
+        newValue: TextEditingValue(
+          text: 'abc',
+          //       \ cursor
+          selection: TextSelection.collapsed(offset: 2), // any
+        ),
+        expected: EditType.unchanged,
+      ),
+
+      _Example(
+        'No change, non-collapsed',
+        oldValue: TextEditingValue(
+          text: 'abc',
+          //      <>
+          selection: TextSelection(baseOffset: 1, extentOffset: 3),
+        ),
+        newValue: TextEditingValue(
+          text: 'abc',
+          //      <>
+          selection: TextSelection(baseOffset: 1, extentOffset: 3),
+        ),
+        expected: EditType.unchanged,
+      ),
+
+      _Example(
+        'Backspace, collapsed',
+        oldValue: TextEditingValue(
+          text: 'abc',
+          //       \ cursor
+          selection: TextSelection.collapsed(offset: 2),
+        ),
+        newValue: TextEditingValue(
+          text: 'ac',
+          //      \ cursor
+          selection: TextSelection.collapsed(offset: 1),
+        ),
+        expected: EditType.backspaceBeforeCollapsedSelection,
+      ),
+
+      _Example(
+        'Delete, collapsed',
+        oldValue: TextEditingValue(
+          text: 'abc',
+          //      \ cursor
+          selection: TextSelection.collapsed(offset: 1),
+        ),
+        newValue: TextEditingValue(
+          text: 'ac',
+          //      \ cursor
+          selection: TextSelection.collapsed(offset: 1),
+        ),
+        expected: EditType.deleteAfterCollapsedSelection,
+      ),
+
+      _Example(
+        'Backspace, collapsed, disambiguated',
+        oldValue: TextEditingValue(
+          text: 'aa',
+          //      \ cursor
+          selection: TextSelection.collapsed(offset: 1),
+        ),
+        newValue: TextEditingValue(
+          text: 'a',
+          //     \ cursor
+          selection: TextSelection.collapsed(offset: 0),
+        ),
+        expected: EditType.backspaceBeforeCollapsedSelection,
+      ),
+
+      _Example(
+        'Delete, collapsed, disambiguated',
+        oldValue: TextEditingValue(
+          text: 'aa',
+          //      \ cursor
+          selection: TextSelection.collapsed(offset: 1),
+        ),
+        newValue: TextEditingValue(
+          text: 'a',
+          //      \ cursor
+          selection: TextSelection.collapsed(offset: 1),
+        ),
+        expected: EditType.deleteAfterCollapsedSelection,
+      ),
+
+      _Example(
+        'Invalid disambiguation, moved home',
+        oldValue: TextEditingValue(
+          text: 'aaaa',
+          //       \ cursor
+          selection: TextSelection.collapsed(offset: 2),
+        ),
+        newValue: TextEditingValue(
+          text: 'aaa',
+          //     \ cursor
+          selection: TextSelection.collapsed(offset: 0),
+        ),
+        expected: EditType.other,
+      ),
+
+      _Example(
+        'Invalid disambiguation, moved to end',
+        oldValue: TextEditingValue(
+          text: 'aaaa',
+          //       \ cursor
+          selection: TextSelection.collapsed(offset: 2),
+        ),
+        newValue: TextEditingValue(
+          text: 'aaa',
+          //        \ cursor
+          selection: TextSelection.collapsed(offset: 3),
+        ),
+        expected: EditType.other,
+      ),
+
+      _Example(
+        'Backspace or delete, non-collapsed',
+        oldValue: TextEditingValue(
+          text: 'abcd',
+          //      <>
+          selection: TextSelection(baseOffset: 1, extentOffset: 3),
+        ),
+        newValue: TextEditingValue(
+          text: 'ad',
+          selection: TextSelection.collapsed(offset: 1),
+        ),
+        expected: EditType.deleteSelection,
+      ),
+
+      _Example(
+        'Partially deleted selection',
+        oldValue: TextEditingValue(
+          text: 'abcd',
+          //      <>
+          selection: TextSelection(baseOffset: 1, extentOffset: 3),
+        ),
+        newValue: TextEditingValue(
+          text: 'acd',
+          //       \ cursor
+          selection: TextSelection.collapsed(offset: 2),
+        ),
+        expected: EditType.replaceSelection,
+      ),
+
+      _Example(
+        'Replace with non-empty',
+        oldValue: TextEditingValue(
+          text: 'abcd',
+          //      <>
+          selection: TextSelection(baseOffset: 1, extentOffset: 3),
+        ),
+        newValue: TextEditingValue(
+          text: 'aBCd',
+          selection: TextSelection.collapsed(offset: 3),
+        ),
+        expected: EditType.replaceSelection,
+      ),
+
+      _Example(
+        'Typed at collapsed',
+        oldValue: TextEditingValue(
+          text: 'ac',
+          //      \ cursor
+          selection: TextSelection.collapsed(offset: 1),
+        ),
+        newValue: TextEditingValue(
+          text: 'abc',
+          //       \ cursor
+          selection: TextSelection.collapsed(offset: 2),
+        ),
+        expected: EditType.insertAtCollapsedSelection,
+      ),
+
+      _Example(
+        'Replace non-selected',
+        oldValue: TextEditingValue(
+          text: 'a',
+          //     \ cursor
+          selection: TextSelection.collapsed(offset: 0),
+        ),
+        newValue: TextEditingValue(
+          text: 'b',
+          //     \ cursor
+          selection: TextSelection.collapsed(offset: 0),
+        ),
+        expected: EditType.other,
+      ),
+    ];
+
+    for (final example in examples) {
+      final result = example.newValue.getEditType(example.oldValue);
+      expect(result, example.expected, reason: example.name);
+    }
+  });
+}
+
+class _Example {
+  const _Example(
+    this.name, {
+    required this.oldValue,
+    required this.newValue,
+    required this.expected,
+  });
+
+  final String name;
+  final TextEditingValue oldValue;
+  final TextEditingValue newValue;
+  final EditType expected;
+}