import 'package:flutter/material.dart'; import 'package:flutter_quill/flutter_quill.dart'; import 'package:flutter_quill/quill_delta.dart'; import 'package:test/test.dart'; void main() { const testDocumentContents = 'data'; late QuillController controller; WidgetsFlutterBinding.ensureInitialized(); 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( const 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(const Style())); }); test('getAllIndividualSelectionStylesAndEmbed', () { controller ..formatText(0, 2, Attribute.bold) ..replaceText(2, 2, BlockEmbed.image('/test'), null) ..updateSelection(const TextSelection(baseOffset: 0, extentOffset: 4), ChangeSource.remote); final result = controller.getAllIndividualSelectionStylesAndEmbed(); expect(result.length, 2); expect(result[0].offset, 0); expect(result[0].value, const Style().put(Attribute.bold)); expect((result[1].value as Embeddable).type, BlockEmbed.imageType); }); test('getAllIndividualSelectionStylesAndEmbed mixed', () { controller ..replaceText(0, 4, 'bold plain italic', null) ..formatText(0, 4, Attribute.bold) ..formatText(11, 17, Attribute.italic) ..updateSelection(const TextSelection(baseOffset: 2, extentOffset: 14), ChangeSource.local); expect(controller.getPlainText(), 'ld plain ita', reason: 'Selection spans 3 styles'); // final result = controller.getAllIndividualSelectionStylesAndEmbed(); expect(result.length, 2); expect(result[0].offset, 0); expect(result[0].length, 2, reason: 'First style is 2 characters bold'); expect(result[0].value, const Style().put(Attribute.bold)); expect(result[1].offset, 9); expect(result[1].length, 3, reason: 'Last style is 3 characters italic'); expect(result[1].value, const Style().put(Attribute.italic)); }); 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(const 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 = const 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(const 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(const Style().put(Attribute.bold))); expect(controller.document.collectAllStyles(2, 4), everyElement(const 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(const Style().put(Attribute.bold))); expect(controller.document.collectAllStyles(2, 4), everyElement(const 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')); }); test('clipboardSelection empty', () { expect(controller.clipboardSelection(true), false, reason: 'No effect when no selection'); expect(controller.clipboardSelection(false), false); }); test('clipboardSelection', () { controller ..replaceText(0, 4, 'bold plain italic', null) ..formatText(0, 4, Attribute.bold) ..formatText(11, 17, Attribute.italic) ..updateSelection(const TextSelection(baseOffset: 2, extentOffset: 14), ChangeSource.local); // expect(controller.clipboardSelection(true), true); expect(controller.document.length, 18, reason: 'Copy does not change the document'); expect(controller.clipboardSelection(false), true); expect(controller.document.length, 6, reason: 'Cut changes the document'); // controller ..readOnly = true ..updateSelection(const TextSelection(baseOffset: 2, extentOffset: 4), ChangeSource.local); expect(controller.selection.isCollapsed, false); expect(controller.clipboardSelection(true), true); expect(controller.document.length, 6); expect(controller.clipboardSelection(false), false); expect(controller.document.length, 6, reason: 'Cut not permitted on readOnly document'); }); test('blockSelectionStyles', () { Style select(int start, int end) { controller.updateSelection( TextSelection(baseOffset: start, extentOffset: end), ChangeSource.local); return controller.getSelectionStyle(); } Attribute fromKey(String key) => switch (key) { 'header' => Attribute.h1, 'list' => Attribute.ol, 'align' => Attribute.centerAlignment, 'code-block' => Attribute.codeBlock, 'blockquote' => Attribute.blockQuote, 'indent' => Attribute.indentL2, 'direction' => Attribute.rtl, 'line-height' => LineHeightAttribute.lineHeightNormal, String() => throw UnimplementedError(key) }; for (final blockKey in Attribute.blockKeys) { final blockAttribute = fromKey(blockKey); controller ..clear() ..replaceText(0, 0, 'line 1\nLine 2\nLine 3', null) ..formatText(0, 0, blockAttribute) // first 2 lines ..formatText( 4, 6, Attribute.bold) // spans end of line 1 and start of line 2 ..formatText(7, 0, blockAttribute); expect(select(2, 5), const Style().put(blockAttribute), reason: 'line 1 block, plain and bold'); expect( select(5, 6), const Style().put(Attribute.bold).put(blockAttribute), reason: 'line 1 block, bold'); expect( select(4, 8), const Style().put(Attribute.bold).put(blockAttribute), reason: 'spans line1 and 2, selection is all bold'); expect(select(4, 11), const Style().put(blockAttribute), reason: 'selection expands into non-bold text'); expect(select(2, 11), const Style().put(blockAttribute), reason: 'selection starts in non-bold text extends into plain on next line'); expect(select(2, 8), const Style().put(blockAttribute), reason: 'selection starts in non-bold text, extends into bold on next line'); expect( select(7, 8), const Style().put(Attribute.bold).put(blockAttribute), reason: 'line 2 block, bold'); expect(select(7, 11), const Style().put(blockAttribute), reason: 'line 2 block, selection extends into plain text'); expect(select(4, 16), const Style(), reason: 'line 1 extends into line3 which is not block'); } }); // TODO: Implement this, check of kIsWeb is not testable in QuillController test('the paste event should be not null on web', () {}); }); }