First pass of tests and simple CI (#1265)
parent
0906f71b53
commit
0fd790e157
9 changed files with 536 additions and 1 deletions
@ -0,0 +1,23 @@ |
|||||||
|
name: flutter-quill CI |
||||||
|
|
||||||
|
on: |
||||||
|
push: |
||||||
|
branches: [master] |
||||||
|
pull_request: |
||||||
|
branches: [master] |
||||||
|
|
||||||
|
jobs: |
||||||
|
tests: |
||||||
|
runs-on: ubuntu-latest |
||||||
|
|
||||||
|
steps: |
||||||
|
- uses: actions/checkout@v3 |
||||||
|
- uses: subosito/flutter-action@v2 |
||||||
|
with: |
||||||
|
channel: 'stable' |
||||||
|
- run: flutter --version |
||||||
|
- run: flutter pub get |
||||||
|
- run: flutter pub get -C flutter_quill_extensions |
||||||
|
- run: flutter analyze |
||||||
|
- run: flutter test |
||||||
|
- run: flutter pub publish --dry-run |
@ -0,0 +1,3 @@ |
|||||||
|
library flutter_quill_test; |
||||||
|
|
||||||
|
export 'src/test/widget_tester_extension.dart'; |
@ -0,0 +1,60 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:flutter_test/flutter_test.dart'; |
||||||
|
|
||||||
|
import '../widgets/editor.dart'; |
||||||
|
import '../widgets/raw_editor.dart'; |
||||||
|
|
||||||
|
/// Extends |
||||||
|
extension QuillEnterText on WidgetTester { |
||||||
|
/// Give the QuillEditor widget specified by [finder] the focus. |
||||||
|
Future<void> quillGiveFocus(Finder finder) { |
||||||
|
return TestAsyncUtils.guard(() async { |
||||||
|
final editor = state<QuillEditorState>( |
||||||
|
find.descendant( |
||||||
|
of: finder, |
||||||
|
matching: |
||||||
|
find.byType(QuillEditor, skipOffstage: finder.skipOffstage), |
||||||
|
matchRoot: true), |
||||||
|
); |
||||||
|
editor.widget.focusNode.requestFocus(); |
||||||
|
await pump(); |
||||||
|
expect(editor.widget.focusNode.hasFocus, isTrue); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/// Give the QuillEditor widget specified by [finder] the focus and update its |
||||||
|
/// editing value with [text], as if it had been provided by the onscreen |
||||||
|
/// keyboard. |
||||||
|
/// |
||||||
|
/// The widget specified by [finder] must be a [QuillEditor] or have a |
||||||
|
/// [QuillEditor] descendant. For example `find.byType(QuillEditor)`. |
||||||
|
Future<void> quillEnterText(Finder finder, String text) async { |
||||||
|
return TestAsyncUtils.guard(() async { |
||||||
|
await quillGiveFocus(finder); |
||||||
|
await quillUpdateEditingValue(finder, text); |
||||||
|
await idle(); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/// Update the text editing value of the QuillEditor widget specified by |
||||||
|
/// [finder] with [text], as if it had been provided by the onscreen keyboard. |
||||||
|
/// |
||||||
|
/// The widget specified by [finder] must already have focus and be a |
||||||
|
/// [QuillEditor] or have a [QuillEditor] descendant. For example |
||||||
|
/// `find.byType(QuillEditor)`. |
||||||
|
Future<void> quillUpdateEditingValue(Finder finder, String text) async { |
||||||
|
return TestAsyncUtils.guard(() async { |
||||||
|
final editor = state<RawEditorState>( |
||||||
|
find.descendant( |
||||||
|
of: finder, |
||||||
|
matching: find.byType(RawEditor, skipOffstage: finder.skipOffstage), |
||||||
|
matchRoot: true), |
||||||
|
); |
||||||
|
testTextInput.updateEditingValue(TextEditingValue( |
||||||
|
text: text, |
||||||
|
selection: TextSelection.collapsed( |
||||||
|
offset: editor.textEditingValue.text.length))); |
||||||
|
await idle(); |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,60 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:flutter_quill/flutter_quill.dart'; |
||||||
|
import 'package:flutter_quill/flutter_quill_test.dart'; |
||||||
|
import 'package:flutter_test/flutter_test.dart'; |
||||||
|
|
||||||
|
void main() { |
||||||
|
group('Bug fix', () { |
||||||
|
group('1189 - The provided text position is not in the current node', () { |
||||||
|
late QuillController controller; |
||||||
|
late QuillEditor editor; |
||||||
|
|
||||||
|
setUp(() { |
||||||
|
controller = QuillController.basic(); |
||||||
|
editor = QuillEditor.basic(controller: controller, readOnly: false); |
||||||
|
}); |
||||||
|
|
||||||
|
tearDown(() { |
||||||
|
controller.dispose(); |
||||||
|
}); |
||||||
|
|
||||||
|
testWidgets('Refocus editor after controller clears document', |
||||||
|
(tester) async { |
||||||
|
await tester.pumpWidget(MaterialApp(home: Column(children: [editor]))); |
||||||
|
await tester.quillEnterText(find.byType(QuillEditor), 'test\n'); |
||||||
|
|
||||||
|
editor.focusNode.unfocus(); |
||||||
|
await tester.pump(); |
||||||
|
controller.clear(); |
||||||
|
editor.focusNode.requestFocus(); |
||||||
|
await tester.pump(); |
||||||
|
expect(tester.takeException(), isNull); |
||||||
|
}); |
||||||
|
|
||||||
|
testWidgets('Refocus editor after removing block attribute', |
||||||
|
(tester) async { |
||||||
|
await tester.pumpWidget(MaterialApp(home: Column(children: [editor]))); |
||||||
|
await tester.quillEnterText(find.byType(QuillEditor), 'test\n'); |
||||||
|
|
||||||
|
controller.formatSelection(Attribute.ul); |
||||||
|
editor.focusNode.unfocus(); |
||||||
|
await tester.pump(); |
||||||
|
controller.formatSelection(const ListAttribute(null)); |
||||||
|
editor.focusNode.requestFocus(); |
||||||
|
await tester.pump(); |
||||||
|
expect(tester.takeException(), isNull); |
||||||
|
}); |
||||||
|
|
||||||
|
testWidgets('Tap checkbox in unfocused editor', (tester) async { |
||||||
|
await tester.pumpWidget(MaterialApp(home: Column(children: [editor]))); |
||||||
|
await tester.quillEnterText(find.byType(QuillEditor), 'test\n'); |
||||||
|
|
||||||
|
controller.formatSelection(Attribute.unchecked); |
||||||
|
editor.focusNode.unfocus(); |
||||||
|
await tester.pump(); |
||||||
|
await tester.tap(find.byType(CheckboxPoint)); |
||||||
|
expect(tester.takeException(), isNull); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
} |
@ -0,0 +1,290 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:flutter_quill/flutter_quill.dart'; |
||||||
|
import 'package:flutter_test/flutter_test.dart'; |
||||||
|
|
||||||
|
void main() { |
||||||
|
const testDocumentContents = 'data'; |
||||||
|
late QuillController controller; |
||||||
|
|
||||||
|
setUp(() { |
||||||
|
controller = QuillController.basic() |
||||||
|
..compose(Delta()..insert(testDocumentContents), |
||||||
|
const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL); |
||||||
|
}); |
||||||
|
|
||||||
|
group('controller', () { |
||||||
|
test('set document', () { |
||||||
|
const replacementContents = 'replacement\n'; |
||||||
|
final newDocument = |
||||||
|
Document.fromDelta(Delta()..insert(replacementContents)); |
||||||
|
var listenerCalled = false; |
||||||
|
controller |
||||||
|
..addListener(() { |
||||||
|
listenerCalled = true; |
||||||
|
}) |
||||||
|
..document = newDocument; |
||||||
|
expect(listenerCalled, isTrue); |
||||||
|
expect(controller.document.toPlainText(), replacementContents); |
||||||
|
}); |
||||||
|
|
||||||
|
test('getSelectionStyle', () { |
||||||
|
controller |
||||||
|
..formatText(0, 5, Attribute.h1) |
||||||
|
..updateSelection(const TextSelection(baseOffset: 0, extentOffset: 4), |
||||||
|
ChangeSource.LOCAL); |
||||||
|
|
||||||
|
expect(controller.getSelectionStyle().values, [Attribute.h1]); |
||||||
|
}); |
||||||
|
|
||||||
|
test('indentSelection with single line document', () { |
||||||
|
var listenerCalled = false; |
||||||
|
// With selection range |
||||||
|
controller |
||||||
|
..updateSelection(const TextSelection(baseOffset: 0, extentOffset: 4), |
||||||
|
ChangeSource.LOCAL) |
||||||
|
..addListener(() { |
||||||
|
listenerCalled = true; |
||||||
|
}) |
||||||
|
..indentSelection(true); |
||||||
|
expect(listenerCalled, isTrue); |
||||||
|
expect(controller.getSelectionStyle().values, [Attribute.indentL1]); |
||||||
|
controller.indentSelection(true); |
||||||
|
expect(controller.getSelectionStyle().values, [Attribute.indentL2]); |
||||||
|
controller.indentSelection(false); |
||||||
|
expect(controller.getSelectionStyle().values, [Attribute.indentL1]); |
||||||
|
controller.indentSelection(false); |
||||||
|
expect(controller.getSelectionStyle().values, []); |
||||||
|
|
||||||
|
// With collapsed selection |
||||||
|
controller |
||||||
|
..updateSelection( |
||||||
|
const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL) |
||||||
|
..indentSelection(true); |
||||||
|
expect(controller.getSelectionStyle().values, [Attribute.indentL1]); |
||||||
|
controller |
||||||
|
..updateSelection( |
||||||
|
const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL) |
||||||
|
..indentSelection(true); |
||||||
|
expect(controller.getSelectionStyle().values, [Attribute.indentL2]); |
||||||
|
controller.indentSelection(false); |
||||||
|
expect(controller.getSelectionStyle().values, [Attribute.indentL1]); |
||||||
|
controller.indentSelection(false); |
||||||
|
expect(controller.getSelectionStyle().values, []); |
||||||
|
}); |
||||||
|
|
||||||
|
test('indentSelection with multiline document', () { |
||||||
|
controller |
||||||
|
..compose(Delta()..insert('line1\nline2\nline3\n'), |
||||||
|
const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL) |
||||||
|
// Indent first line |
||||||
|
..updateSelection( |
||||||
|
const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL) |
||||||
|
..indentSelection(true); |
||||||
|
expect(controller.getSelectionStyle().values, [Attribute.indentL1]); |
||||||
|
|
||||||
|
// Indent first two lines |
||||||
|
controller |
||||||
|
..updateSelection(const TextSelection(baseOffset: 0, extentOffset: 11), |
||||||
|
ChangeSource.LOCAL) |
||||||
|
..indentSelection(true); |
||||||
|
|
||||||
|
// Should have both L1 and L2 indent attributes in selection. |
||||||
|
expect(controller.getAllSelectionStyles(), |
||||||
|
contains(Style().put(Attribute.indentL1).put(Attribute.indentL2))); |
||||||
|
|
||||||
|
// Remaining lines should have no attributes. |
||||||
|
controller.updateSelection( |
||||||
|
TextSelection( |
||||||
|
baseOffset: 12, |
||||||
|
extentOffset: controller.document.toPlainText().length - 1), |
||||||
|
ChangeSource.LOCAL); |
||||||
|
expect(controller.getAllSelectionStyles(), everyElement(Style())); |
||||||
|
}); |
||||||
|
|
||||||
|
test('getAllIndividualSelectionStyles', () { |
||||||
|
controller.formatText(0, 2, Attribute.bold); |
||||||
|
final result = controller.getAllIndividualSelectionStyles(); |
||||||
|
expect(result.length, 1); |
||||||
|
expect(result[0].offset, 0); |
||||||
|
expect(result[0].value, Style().put(Attribute.bold)); |
||||||
|
}); |
||||||
|
|
||||||
|
test('getPlainText', () { |
||||||
|
controller.updateSelection( |
||||||
|
const TextSelection(baseOffset: 0, extentOffset: 4), |
||||||
|
ChangeSource.LOCAL); |
||||||
|
|
||||||
|
expect(controller.getPlainText(), testDocumentContents); |
||||||
|
}); |
||||||
|
|
||||||
|
test('getAllSelectionStyles', () { |
||||||
|
controller.formatText(0, 2, Attribute.bold); |
||||||
|
expect(controller.getAllSelectionStyles(), |
||||||
|
contains(Style().put(Attribute.bold))); |
||||||
|
}); |
||||||
|
|
||||||
|
test('undo', () { |
||||||
|
var listenerCalled = false; |
||||||
|
controller.updateSelection( |
||||||
|
const TextSelection.collapsed(offset: 4), ChangeSource.LOCAL); |
||||||
|
|
||||||
|
expect(controller.document.toDelta(), Delta()..insert('data\n')); |
||||||
|
controller |
||||||
|
..addListener(() { |
||||||
|
listenerCalled = true; |
||||||
|
}) |
||||||
|
..undo(); |
||||||
|
expect(listenerCalled, isTrue); |
||||||
|
expect(controller.document.toDelta(), Delta()..insert('\n')); |
||||||
|
}); |
||||||
|
|
||||||
|
test('redo', () { |
||||||
|
var listenerCalled = false; |
||||||
|
controller.updateSelection( |
||||||
|
const TextSelection.collapsed(offset: 4), ChangeSource.LOCAL); |
||||||
|
|
||||||
|
expect(controller.document.toDelta(), Delta()..insert('data\n')); |
||||||
|
controller.undo(); |
||||||
|
expect(controller.document.toDelta(), Delta()..insert('\n')); |
||||||
|
controller |
||||||
|
..addListener(() { |
||||||
|
listenerCalled = true; |
||||||
|
}) |
||||||
|
..redo(); |
||||||
|
expect(listenerCalled, isTrue); |
||||||
|
expect(controller.document.toDelta(), Delta()..insert('data\n')); |
||||||
|
}); |
||||||
|
test('clear', () { |
||||||
|
var listenerCalled = false; |
||||||
|
controller |
||||||
|
..addListener(() { |
||||||
|
listenerCalled = true; |
||||||
|
}) |
||||||
|
..clear(); |
||||||
|
|
||||||
|
expect(listenerCalled, isTrue); |
||||||
|
expect(controller.document.toDelta(), Delta()..insert('\n')); |
||||||
|
}); |
||||||
|
|
||||||
|
test('replaceText', () { |
||||||
|
var listenerCalled = false; |
||||||
|
controller |
||||||
|
..addListener(() { |
||||||
|
listenerCalled = true; |
||||||
|
}) |
||||||
|
..replaceText(1, 2, '11', const TextSelection.collapsed(offset: 0)); |
||||||
|
|
||||||
|
expect(listenerCalled, isTrue); |
||||||
|
expect(controller.document.toDelta(), Delta()..insert('d11a\n')); |
||||||
|
}); |
||||||
|
|
||||||
|
test('formatTextStyle', () { |
||||||
|
var listenerCalled = false; |
||||||
|
final style = Style().put(Attribute.bold).put(Attribute.italic); |
||||||
|
controller |
||||||
|
..addListener(() { |
||||||
|
listenerCalled = true; |
||||||
|
}) |
||||||
|
..formatTextStyle(0, 2, style); |
||||||
|
expect(listenerCalled, isTrue); |
||||||
|
expect(controller.document.collectAllStyles(0, 2), contains(style)); |
||||||
|
expect(controller.document.collectAllStyles(2, 4), everyElement(Style())); |
||||||
|
}); |
||||||
|
|
||||||
|
test('formatText', () { |
||||||
|
var listenerCalled = false; |
||||||
|
controller |
||||||
|
..addListener(() { |
||||||
|
listenerCalled = true; |
||||||
|
}) |
||||||
|
..formatText(0, 2, Attribute.bold); |
||||||
|
expect(listenerCalled, isTrue); |
||||||
|
expect(controller.document.collectAllStyles(0, 2), |
||||||
|
contains(Style().put(Attribute.bold))); |
||||||
|
expect(controller.document.collectAllStyles(2, 4), everyElement(Style())); |
||||||
|
}); |
||||||
|
|
||||||
|
test('formatSelection', () { |
||||||
|
var listenerCalled = false; |
||||||
|
controller |
||||||
|
..updateSelection(const TextSelection(baseOffset: 0, extentOffset: 2), |
||||||
|
ChangeSource.LOCAL) |
||||||
|
..addListener(() { |
||||||
|
listenerCalled = true; |
||||||
|
}) |
||||||
|
..formatSelection(Attribute.bold); |
||||||
|
expect(listenerCalled, isTrue); |
||||||
|
expect(controller.document.collectAllStyles(0, 2), |
||||||
|
contains(Style().put(Attribute.bold))); |
||||||
|
expect(controller.document.collectAllStyles(2, 4), everyElement(Style())); |
||||||
|
}); |
||||||
|
|
||||||
|
test('moveCursorToStart', () { |
||||||
|
var listenerCalled = false; |
||||||
|
controller |
||||||
|
..updateSelection( |
||||||
|
const TextSelection.collapsed(offset: 4), ChangeSource.LOCAL) |
||||||
|
..addListener(() { |
||||||
|
listenerCalled = true; |
||||||
|
}); |
||||||
|
expect(controller.selection, const TextSelection.collapsed(offset: 4)); |
||||||
|
|
||||||
|
controller.moveCursorToStart(); |
||||||
|
expect(listenerCalled, isTrue); |
||||||
|
expect(controller.selection, const TextSelection.collapsed(offset: 0)); |
||||||
|
}); |
||||||
|
|
||||||
|
test('moveCursorToPosition', () { |
||||||
|
var listenerCalled = false; |
||||||
|
controller.addListener(() { |
||||||
|
listenerCalled = true; |
||||||
|
}); |
||||||
|
expect(controller.selection, const TextSelection.collapsed(offset: 0)); |
||||||
|
|
||||||
|
controller.moveCursorToPosition(2); |
||||||
|
expect(listenerCalled, isTrue); |
||||||
|
expect(controller.selection, const TextSelection.collapsed(offset: 2)); |
||||||
|
}); |
||||||
|
|
||||||
|
test('moveCursorToEnd', () { |
||||||
|
var listenerCalled = false; |
||||||
|
controller.addListener(() { |
||||||
|
listenerCalled = true; |
||||||
|
}); |
||||||
|
expect(controller.selection, const TextSelection.collapsed(offset: 0)); |
||||||
|
|
||||||
|
controller.moveCursorToEnd(); |
||||||
|
expect(listenerCalled, isTrue); |
||||||
|
expect(controller.selection, |
||||||
|
TextSelection.collapsed(offset: controller.document.length - 1)); |
||||||
|
}); |
||||||
|
|
||||||
|
test('updateSelection', () { |
||||||
|
var listenerCalled = false; |
||||||
|
const selection = TextSelection.collapsed(offset: 0); |
||||||
|
controller |
||||||
|
..addListener(() { |
||||||
|
listenerCalled = true; |
||||||
|
}) |
||||||
|
..updateSelection(selection, ChangeSource.LOCAL); |
||||||
|
|
||||||
|
expect(listenerCalled, isTrue); |
||||||
|
expect(controller.selection, selection); |
||||||
|
}); |
||||||
|
|
||||||
|
test('compose', () { |
||||||
|
var listenerCalled = false; |
||||||
|
final originalContents = controller.document.toPlainText(); |
||||||
|
controller |
||||||
|
..addListener(() { |
||||||
|
listenerCalled = true; |
||||||
|
}) |
||||||
|
..compose(Delta()..insert('test '), |
||||||
|
const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL); |
||||||
|
|
||||||
|
expect(listenerCalled, isTrue); |
||||||
|
expect(controller.document.toDelta(), |
||||||
|
Delta()..insert('test $originalContents')); |
||||||
|
}); |
||||||
|
}); |
||||||
|
} |
@ -0,0 +1,82 @@ |
|||||||
|
import 'dart:convert' show jsonDecode; |
||||||
|
|
||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:flutter/services.dart'; |
||||||
|
import 'package:flutter_quill/flutter_quill.dart'; |
||||||
|
import 'package:flutter_quill/flutter_quill_test.dart'; |
||||||
|
import 'package:flutter_test/flutter_test.dart'; |
||||||
|
|
||||||
|
void main() { |
||||||
|
late QuillController controller; |
||||||
|
|
||||||
|
setUp(() { |
||||||
|
controller = QuillController.basic(); |
||||||
|
}); |
||||||
|
|
||||||
|
tearDown(() { |
||||||
|
controller.dispose(); |
||||||
|
}); |
||||||
|
|
||||||
|
group('QuillEditor', () { |
||||||
|
testWidgets('Keyboard entered text is stored in document', (tester) async { |
||||||
|
await tester.pumpWidget( |
||||||
|
MaterialApp( |
||||||
|
home: QuillEditor.basic(controller: controller, readOnly: false), |
||||||
|
), |
||||||
|
); |
||||||
|
await tester.quillEnterText(find.byType(QuillEditor), 'test\n'); |
||||||
|
|
||||||
|
expect(controller.document.toPlainText(), 'test\n'); |
||||||
|
}); |
||||||
|
|
||||||
|
testWidgets('insertContent is handled correctly', (tester) async { |
||||||
|
String? latestUri; |
||||||
|
await tester.pumpWidget( |
||||||
|
MaterialApp( |
||||||
|
home: QuillEditor( |
||||||
|
controller: controller, |
||||||
|
focusNode: FocusNode(), |
||||||
|
scrollController: ScrollController(), |
||||||
|
scrollable: true, |
||||||
|
padding: const EdgeInsets.all(0), |
||||||
|
autoFocus: true, |
||||||
|
readOnly: false, |
||||||
|
expands: true, |
||||||
|
contentInsertionConfiguration: ContentInsertionConfiguration( |
||||||
|
onContentInserted: (content) { |
||||||
|
latestUri = content.uri; |
||||||
|
}, |
||||||
|
allowedMimeTypes: const <String>['image/gif'], |
||||||
|
), |
||||||
|
), |
||||||
|
), |
||||||
|
); |
||||||
|
await tester.tap(find.byType(QuillEditor)); |
||||||
|
await tester.quillEnterText(find.byType(QuillEditor), 'test\n'); |
||||||
|
await tester.idle(); |
||||||
|
|
||||||
|
const uri = |
||||||
|
'content://com.google.android.inputmethod.latin.fileprovider/test.gif'; |
||||||
|
final messageBytes = |
||||||
|
const JSONMessageCodec().encodeMessage(<String, dynamic>{ |
||||||
|
'args': <dynamic>[ |
||||||
|
-1, |
||||||
|
'TextInputAction.commitContent', |
||||||
|
jsonDecode( |
||||||
|
'{"mimeType": "image/gif", "data": [0,1,0,1,0,1,0,0,0], "uri": "$uri"}'), |
||||||
|
], |
||||||
|
'method': 'TextInputClient.performAction', |
||||||
|
}); |
||||||
|
|
||||||
|
Object? error; |
||||||
|
try { |
||||||
|
await tester.binding.defaultBinaryMessenger |
||||||
|
.handlePlatformMessage('flutter/textinput', messageBytes, (_) {}); |
||||||
|
} catch (e) { |
||||||
|
error = e; |
||||||
|
} |
||||||
|
expect(error, isNull); |
||||||
|
expect(latestUri, equals(uri)); |
||||||
|
}); |
||||||
|
}); |
||||||
|
} |
Loading…
Reference in new issue