Unverified Commit 329afbe9 authored by Renzo Olivares's avatar Renzo Olivares Committed by GitHub

updateEditingValueWithDeltas should fail loudly when TextRange is invalid (#107426)

* Make deltas fail loudly

* analyzer fixes

* empty

* updates

* Analyzer fixes

* Make it more obvious what kind of TextRange is failing and where

* update tests

* Add tests for concrete TextEditinDelta apply method

* trailing spaces

* address nits

* fix analyzer
Co-authored-by: 's avatarRenzo Olivares <roliv@google.com>
parent 2600b2d9
......@@ -24,13 +24,21 @@ TextAffinity? _toTextAffinity(String? affinity) {
return null;
}
/// Replaces a range of text in the original string with the text given in the
/// replacement string.
String _replace(String originalText, String replacementText, int start, int end) {
final String textStart = originalText.substring(0, start);
final String textEnd = originalText.substring(end, originalText.length);
final String newText = textStart + replacementText + textEnd;
return newText;
// Replaces a range of text in the original string with the text given in the
// replacement string.
String _replace(String originalText, String replacementText, TextRange replacementRange) {
assert(replacementRange.isValid);
return originalText.replaceRange(replacementRange.start, replacementRange.end, replacementText);
}
// Verify that the given range is within the text.
bool _debugTextRangeIsValid(TextRange range, String text) {
if (!range.isValid) {
return true;
}
return (range.start >= 0 && range.start <= text.length)
&& (range.end >= 0 && range.end <= text.length);
}
/// A structure representing a granular change that has occurred to the editing
......@@ -126,6 +134,9 @@ abstract class TextEditingDelta {
);
if (isNonTextUpdate) {
assert(_debugTextRangeIsValid(newSelection, oldText), 'The selection range: $newSelection is not within the bounds of text: $oldText of length: ${oldText.length}');
assert(_debugTextRangeIsValid(newComposing, oldText), 'The composing range: $newComposing is not within the bounds of text: $oldText of length: ${oldText.length}');
return TextEditingDeltaNonTextUpdate(
oldText: oldText,
selection: newSelection,
......@@ -133,7 +144,13 @@ abstract class TextEditingDelta {
);
}
final String newText = _replace(oldText, replacementSource, replacementDestinationStart, replacementDestinationEnd);
assert(_debugTextRangeIsValid(TextRange(start: replacementDestinationStart, end: replacementDestinationEnd), oldText), 'The delta range: ${TextRange(start: replacementSourceStart, end: replacementSourceEnd)} is not within the bounds of text: $oldText of length: ${oldText.length}');
final String newText = _replace(oldText, replacementSource, TextRange(start: replacementDestinationStart, end: replacementDestinationEnd));
assert(_debugTextRangeIsValid(newSelection, newText), 'The selection range: $newSelection is not within the bounds of text: $newText of length: ${newText.length}');
assert(_debugTextRangeIsValid(newComposing, newText), 'The composing range: $newComposing is not within the bounds of text: $newText of length: ${newText.length}');
final bool isEqual = oldText == newText;
final bool isDeletionGreaterThanOne = (replacementDestinationEnd - replacementDestinationStart) - (replacementSourceEnd - replacementSourceStart) > 1;
......@@ -265,7 +282,10 @@ class TextEditingDeltaInsertion extends TextEditingDelta {
// policy and apply the delta to the oldText. This is due to the asyncronous
// nature of the connection between the framework and platform text input plugins.
String newText = oldText;
newText = _replace(newText, textInserted, insertionOffset, insertionOffset);
assert(_debugTextRangeIsValid(TextRange.collapsed(insertionOffset), newText), 'Applying TextEditingDeltaInsertion failed, the insertionOffset: $insertionOffset is not within the bounds of $newText of length: ${newText.length}');
newText = _replace(newText, textInserted, TextRange.collapsed(insertionOffset));
assert(_debugTextRangeIsValid(selection, newText), 'Applying TextEditingDeltaInsertion failed, the selection range: $selection is not within the bounds of $newText of length: ${newText.length}');
assert(_debugTextRangeIsValid(composing, newText), 'Applying TextEditingDeltaInsertion failed, the composing range: $composing is not within the bounds of $newText of length: ${newText.length}');
return value.copyWith(text: newText, selection: selection, composing: composing);
}
}
......@@ -298,7 +318,10 @@ class TextEditingDeltaDeletion extends TextEditingDelta {
// policy and apply the delta to the oldText. This is due to the asyncronous
// nature of the connection between the framework and platform text input plugins.
String newText = oldText;
newText = _replace(newText, '', deletedRange.start, deletedRange.end);
assert(_debugTextRangeIsValid(deletedRange, newText), 'Applying TextEditingDeltaDeletion failed, the deletedRange: $deletedRange is not within the bounds of $newText of length: ${newText.length}');
newText = _replace(newText, '', deletedRange);
assert(_debugTextRangeIsValid(selection, newText), 'Applying TextEditingDeltaDeletion failed, the selection range: $selection is not within the bounds of $newText of length: ${newText.length}');
assert(_debugTextRangeIsValid(composing, newText), 'Applying TextEditingDeltaDeletion failed, the composing range: $composing is not within the bounds of $newText of length: ${newText.length}');
return value.copyWith(text: newText, selection: selection, composing: composing);
}
}
......@@ -341,7 +364,10 @@ class TextEditingDeltaReplacement extends TextEditingDelta {
// policy and apply the delta to the oldText. This is due to the asyncronous
// nature of the connection between the framework and platform text input plugins.
String newText = oldText;
newText = _replace(newText, replacementText, replacedRange.start, replacedRange.end);
assert(_debugTextRangeIsValid(replacedRange, newText), 'Applying TextEditingDeltaReplacement failed, the replacedRange: $replacedRange is not within the bounds of $newText of length: ${newText.length}');
newText = _replace(newText, replacementText, replacedRange);
assert(_debugTextRangeIsValid(selection, newText), 'Applying TextEditingDeltaReplacement failed, the selection range: $selection is not within the bounds of $newText of length: ${newText.length}');
assert(_debugTextRangeIsValid(composing, newText), 'Applying TextEditingDeltaReplacement failed, the composing range: $composing is not within the bounds of $newText of length: ${newText.length}');
return value.copyWith(text: newText, selection: selection, composing: composing);
}
}
......@@ -372,6 +398,8 @@ class TextEditingDeltaNonTextUpdate extends TextEditingDelta {
// To stay inline with the plain text model we should follow a last write wins
// policy and apply the delta to the oldText. This is due to the asyncronous
// nature of the connection between the framework and platform text input plugins.
assert(_debugTextRangeIsValid(selection, oldText), 'Applying TextEditingDeltaNonTextUpdate failed, the selection range: $selection is not within the bounds of $oldText of length: ${oldText.length}');
assert(_debugTextRangeIsValid(composing, oldText), 'Applying TextEditingDeltaNonTextUpdate failed, the composing region: $composing is not within the bounds of $oldText of length: ${oldText.length}');
return TextEditingValue(text: oldText, selection: selection, composing: composing);
}
}
......@@ -4,6 +4,7 @@
import 'dart:convert' show jsonDecode;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
......@@ -65,6 +66,162 @@ void main() {
expect(client.latestMethodCall, 'updateEditingValueWithDeltas');
},
);
test('Invalid TextRange fails loudly when being converted to JSON - NonTextUpdate', () async {
final List<FlutterErrorDetails> record = <FlutterErrorDetails>[];
FlutterError.onError = (FlutterErrorDetails details) {
record.add(details);
};
final FakeDeltaTextInputClient client = FakeDeltaTextInputClient(const TextEditingValue(text: '1'));
const TextInputConfiguration configuration = TextInputConfiguration(enableDeltaModel: true);
TextInput.attach(client, configuration);
const String jsonDelta = '{'
'"oldText": "1",'
' "deltaText": "",'
' "deltaStart": -1,'
' "deltaEnd": -1,'
' "selectionBase": 3,'
' "selectionExtent": 3,'
' "selectionAffinity" : "TextAffinity.downstream" ,'
' "selectionIsDirectional": false,'
' "composingBase": -1,'
' "composingExtent": -1}';
final ByteData? messageBytes = const JSONMessageCodec().encodeMessage(<String, dynamic>{
'method': 'TextInputClient.updateEditingStateWithDeltas',
'args': <dynamic>[-1, jsonDecode('{"deltas": [$jsonDelta]}')],
});
await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage(
'flutter/textinput',
messageBytes,
(ByteData? _) {},
);
expect(record.length, 1);
// Verify the error message in parts because Web formats the message
// differently from others.
expect(record[0].exception.toString(), matches(RegExp(r'\bThe selection range: TextSelection.collapsed\(offset: 3, affinity: TextAffinity.downstream, isDirectional: false\)(?!\w)')));
expect(record[0].exception.toString(), matches(RegExp(r'\bis not within the bounds of text: 1 of length: 1\b')));
});
test('Invalid TextRange fails loudly when being converted to JSON - Faulty deltaStart and deltaEnd', () async {
final List<FlutterErrorDetails> record = <FlutterErrorDetails>[];
FlutterError.onError = (FlutterErrorDetails details) {
record.add(details);
};
final FakeDeltaTextInputClient client = FakeDeltaTextInputClient(TextEditingValue.empty);
const TextInputConfiguration configuration = TextInputConfiguration(enableDeltaModel: true);
TextInput.attach(client, configuration);
const String jsonDelta = '{'
'"oldText": "",'
' "deltaText": "hello",'
' "deltaStart": 0,'
' "deltaEnd": 1,'
' "selectionBase": 5,'
' "selectionExtent": 5,'
' "selectionAffinity" : "TextAffinity.downstream" ,'
' "selectionIsDirectional": false,'
' "composingBase": -1,'
' "composingExtent": -1}';
final ByteData? messageBytes = const JSONMessageCodec().encodeMessage(<String, dynamic>{
'method': 'TextInputClient.updateEditingStateWithDeltas',
'args': <dynamic>[-1, jsonDecode('{"deltas": [$jsonDelta]}')],
});
await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage(
'flutter/textinput',
messageBytes,
(ByteData? _) {},
);
expect(record.length, 1);
// Verify the error message in parts because Web formats the message
// differently from others.
expect(record[0].exception.toString(), matches(RegExp(r'\bThe delta range: TextRange\(start: 0, end: 5\)(?!\w)')));
expect(record[0].exception.toString(), matches(RegExp(r'\bis not within the bounds of text: of length: 0\b')));
});
test('Invalid TextRange fails loudly when being converted to JSON - Faulty Selection', () async {
final List<FlutterErrorDetails> record = <FlutterErrorDetails>[];
FlutterError.onError = (FlutterErrorDetails details) {
record.add(details);
};
final FakeDeltaTextInputClient client = FakeDeltaTextInputClient(TextEditingValue.empty);
const TextInputConfiguration configuration = TextInputConfiguration(enableDeltaModel: true);
TextInput.attach(client, configuration);
const String jsonDelta = '{'
'"oldText": "",'
' "deltaText": "hello",'
' "deltaStart": 0,'
' "deltaEnd": 0,'
' "selectionBase": 6,'
' "selectionExtent": 6,'
' "selectionAffinity" : "TextAffinity.downstream" ,'
' "selectionIsDirectional": false,'
' "composingBase": -1,'
' "composingExtent": -1}';
final ByteData? messageBytes = const JSONMessageCodec().encodeMessage(<String, dynamic>{
'method': 'TextInputClient.updateEditingStateWithDeltas',
'args': <dynamic>[-1, jsonDecode('{"deltas": [$jsonDelta]}')],
});
await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage(
'flutter/textinput',
messageBytes,
(ByteData? _) {},
);
expect(record.length, 1);
// Verify the error message in parts because Web formats the message
// differently from others.
expect(record[0].exception.toString(), matches(RegExp(r'\bThe selection range: TextSelection.collapsed\(offset: 6, affinity: TextAffinity.downstream, isDirectional: false\)(?!\w)')));
expect(record[0].exception.toString(), matches(RegExp(r'\bis not within the bounds of text: hello of length: 5\b')));
});
test('Invalid TextRange fails loudly when being converted to JSON - Faulty Composing Region', () async {
final List<FlutterErrorDetails> record = <FlutterErrorDetails>[];
FlutterError.onError = (FlutterErrorDetails details) {
record.add(details);
};
final FakeDeltaTextInputClient client = FakeDeltaTextInputClient(const TextEditingValue(text: 'worl'));
const TextInputConfiguration configuration = TextInputConfiguration(enableDeltaModel: true);
TextInput.attach(client, configuration);
const String jsonDelta = '{'
'"oldText": "worl",'
' "deltaText": "world",'
' "deltaStart": 0,'
' "deltaEnd": 4,'
' "selectionBase": 5,'
' "selectionExtent": 5,'
' "selectionAffinity" : "TextAffinity.downstream" ,'
' "selectionIsDirectional": false,'
' "composingBase": 0,'
' "composingExtent": 6}';
final ByteData? messageBytes = const JSONMessageCodec().encodeMessage(<String, dynamic>{
'method': 'TextInputClient.updateEditingStateWithDeltas',
'args': <dynamic>[-1, jsonDecode('{"deltas": [$jsonDelta]}')],
});
await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage(
'flutter/textinput',
messageBytes,
(ByteData? _) {},
);
expect(record.length, 1);
// Verify the error message in parts because Web formats the message
// differently from others.
expect(record[0].exception.toString(), matches(RegExp(r'\bThe composing range: TextRange\(start: 0, end: 6\)(?!\w)')));
expect(record[0].exception.toString(), matches(RegExp(r'\bis not within the bounds of text: world of length: 5\b')));
});
});
}
......
......@@ -57,6 +57,19 @@ void main() {
expect(delta.selection, expectedSelection);
expect(delta.composing, expectedComposing);
});
test('Verify invalid TextEditingDeltaInsertion fails to apply', () {
const TextEditingDeltaInsertion delta =
TextEditingDeltaInsertion(
oldText: 'hello worl',
textInserted: 'd',
insertionOffset: 11,
selection: TextSelection.collapsed(offset: 11),
composing: TextRange.empty,
);
expect(() { delta.apply(TextEditingValue.empty); }, throwsAssertionError);
});
});
group('TextEditingDeltaDeletion', () {
......@@ -109,6 +122,18 @@ void main() {
expect(delta.selection, expectedSelection);
expect(delta.composing, expectedComposing);
});
test('Verify invalid TextEditingDeltaDeletion fails to apply', () {
const TextEditingDeltaDeletion delta =
TextEditingDeltaDeletion(
oldText: 'hello world',
deletedRange: TextRange(start: 5, end: 12),
selection: TextSelection.collapsed(offset: 5),
composing: TextRange.empty,
);
expect(() { delta.apply(TextEditingValue.empty); }, throwsAssertionError);
});
});
group('TextEditingDeltaReplacement', () {
......@@ -189,6 +214,19 @@ void main() {
expect(delta.selection, expectedSelection);
expect(delta.composing, expectedComposing);
});
test('Verify invalid TextEditingDeltaReplacement fails to apply', () {
const TextEditingDeltaReplacement delta =
TextEditingDeltaReplacement(
oldText: 'hello worl',
replacementText: 'world',
replacedRange: TextRange(start: 5, end: 11),
selection: TextSelection.collapsed(offset: 11),
composing: TextRange.empty,
);
expect(() { delta.apply(TextEditingValue.empty); }, throwsAssertionError);
});
});
group('TextEditingDeltaNonTextUpdate', () {
......@@ -213,5 +251,16 @@ void main() {
expect(delta.selection, expectedSelection);
expect(delta.composing, expectedComposing);
});
test('Verify invalid TextEditingDeltaNonTextUpdate fails to apply', () {
const TextEditingDeltaNonTextUpdate delta =
TextEditingDeltaNonTextUpdate(
oldText: 'hello world',
selection: TextSelection.collapsed(offset: 12),
composing: TextRange.empty,
);
expect(() { delta.apply(TextEditingValue.empty); }, throwsAssertionError);
});
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment