From a59db6b1b5c2f927bcbfdf08b368073eb404f89b Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su <30667958+hjiangsu@users.noreply.github.com> Date: Fri, 12 Apr 2024 08:24:02 -0700 Subject: [PATCH] Fix existing tests, added tests for spoilers, and adjusted spoiler logic (#8) This PR contains the following changes: - Fixed all existing tests that were failing - Added some spoiler tests for formatting to markdown, and widget tests - Adjusted some of the logic for how spoilers are created. This is to allow inline selection of content to create spoilers - Additionally, I've adjusted to cursor positioning so that the cursor lands at the end of the spoiler body https://github.com/thunder-app/markdown-editor/assets/30667958/580aef1d-b95d-4c69-a318-65514b69f84b --- example/lib/main.dart | 1 + lib/src/format_markdown.dart | 12 +- lib/src/markdown_text_input_field.dart | 6 +- test/format_markdown_test.dart | 134 +++++++++++++- test/markdown_text_input_test.dart | 232 ++++++++++++++++++------- 5 files changed, 316 insertions(+), 69 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 18272c5..3a923ed 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -100,6 +100,7 @@ class _AppState extends State { MarkdownType.link, MarkdownType.bold, MarkdownType.italic, + MarkdownType.spoiler, MarkdownType.blockquote, MarkdownType.strikethrough, MarkdownType.title, diff --git a/lib/src/format_markdown.dart b/lib/src/format_markdown.dart index 607e4f6..a178a74 100644 --- a/lib/src/format_markdown.dart +++ b/lib/src/format_markdown.dart @@ -87,9 +87,17 @@ class FormatMarkdown { replaceCursorIndex = 3; break; case MarkdownType.spoiler: - changedData = '::: spoiler Spoiler\n$selectedText\n:::'; + if (fromIndex == 0 && toIndex == data.length) { + // If the entire data is selected, then convert to spoiler + changedData = '::: spoiler Spoiler\n${data.substring(fromIndex, toIndex)}\n:::'; + cursorIndex = 20; + } else { + // If part of the data is selected, then add new lines when necessary + changedData = '${fromIndex == 0 ? '' : '\n'}::: spoiler Spoiler\n${data.substring(fromIndex, toIndex)}\n:::${toIndex == data.length ? '' : '\n'}'; + cursorIndex = fromIndex == 0 ? 20 + data.substring(fromIndex, toIndex).length : 21 + data.substring(fromIndex, toIndex).length; + } + replaceCursorIndex = 0; - cursorIndex = 20 + selectedText.length; break; case MarkdownType.username: case MarkdownType.community: diff --git a/lib/src/markdown_text_input_field.dart b/lib/src/markdown_text_input_field.dart index f421ead..eb538b4 100644 --- a/lib/src/markdown_text_input_field.dart +++ b/lib/src/markdown_text_input_field.dart @@ -35,6 +35,9 @@ class MarkdownTextInputField extends StatefulWidget { /// Overrides input text style final TextStyle? textStyle; + /// Configuration for spell checking + final SpellCheckConfiguration? spellCheckConfiguration; + const MarkdownTextInputField({ super.key, required this.controller, @@ -47,6 +50,7 @@ class MarkdownTextInputField extends StatefulWidget { this.minLines, this.maxLines, this.textStyle, + this.spellCheckConfiguration, }); @override @@ -80,7 +84,7 @@ class _MarkdownTextInputFieldState extends State { return Column( children: [ TextFormField( - spellCheckConfiguration: kIsWeb ? null : const SpellCheckConfiguration(), + spellCheckConfiguration: widget.spellCheckConfiguration ?? (kIsWeb ? null : const SpellCheckConfiguration()), minLines: widget.minLines, focusNode: widget.focusNode, textInputAction: TextInputAction.newline, diff --git a/test/format_markdown_test.dart b/test/format_markdown_test.dart index 89ff38b..f94e844 100644 --- a/test/format_markdown_test.dart +++ b/test/format_markdown_test.dart @@ -471,8 +471,8 @@ void main() { test('successfully converts to image link (RTL)', () { String testString = 'Lorem ipsum dolor sit amet consectetur adipiscing elit.'; - int from = 12; - int to = 17; + int from = 17; + int to = 12; ResultMarkdown formattedText = FormatMarkdown.convertToMarkdown( MarkdownType.image, @@ -485,5 +485,133 @@ void main() { expect(formattedText.cursorIndex, 15, reason: "dolor length = 5, '![](dolor)'= 10"); }); - // TODO: Add tests for username, community, spoiler + test('successfully converts to spoiler (LTR)', () { + String testString = 'Lorem ipsum dolor sit amet consectetur adipiscing elit.'; + int from = 12; + int to = 17; + + ResultMarkdown formattedText = FormatMarkdown.convertToMarkdown( + MarkdownType.spoiler, + testString, + from, + to, + ); + + expect(formattedText.data, 'Lorem ipsum \n::: spoiler Spoiler\ndolor\n:::\n sit amet consectetur adipiscing elit.'); + expect(formattedText.cursorIndex, 26, reason: "dolor length = 5, '\n::: spoiler Spoiler\ndolor\n:::\n'= 31"); + }); + + test('successfully converts to spoiler (RTL)', () { + String testString = 'Lorem ipsum dolor sit amet consectetur adipiscing elit.'; + int from = 17; + int to = 12; + + ResultMarkdown formattedText = FormatMarkdown.convertToMarkdown( + MarkdownType.spoiler, + testString, + from, + to, + ); + + expect(formattedText.data, 'Lorem ipsum \n::: spoiler Spoiler\ndolor\n:::\n sit amet consectetur adipiscing elit.'); + expect(formattedText.cursorIndex, 26, reason: "dolor length = 5, '\n::: spoiler Spoiler\ndolor\n:::\n'= 31"); + }); + + test('successfully converts to spoiler (partial with start index at 0) (LTR)', () { + String testString = 'Lorem ipsum dolor sit amet consectetur adipiscing elit.'; + int from = 0; + int to = 5; + + ResultMarkdown formattedText = FormatMarkdown.convertToMarkdown( + MarkdownType.spoiler, + testString, + from, + to, + ); + + expect(formattedText.data, '::: spoiler Spoiler\nLorem\n:::\n ipsum dolor sit amet consectetur adipiscing elit.'); + expect(formattedText.cursorIndex, 25, reason: "::: spoiler Spoiler\nLorem = 25"); + }); + + test('successfully converts to spoiler (partial with start index at 0) (RTL)', () { + String testString = 'Lorem ipsum dolor sit amet consectetur adipiscing elit.'; + int from = 5; + int to = 0; + + ResultMarkdown formattedText = FormatMarkdown.convertToMarkdown( + MarkdownType.spoiler, + testString, + from, + to, + ); + + expect(formattedText.data, '::: spoiler Spoiler\nLorem\n:::\n ipsum dolor sit amet consectetur adipiscing elit.'); + expect(formattedText.cursorIndex, 25, reason: "::: spoiler Spoiler\nLorem = 25"); + }); + + test('successfully converts to spoiler (partial with start index in the middle) (LTR)', () { + String testString = 'Lorem ipsum dolor sit amet consectetur adipiscing elit.'; + int from = 6; + int to = 11; + + ResultMarkdown formattedText = FormatMarkdown.convertToMarkdown( + MarkdownType.spoiler, + testString, + from, + to, + ); + + expect(formattedText.data, 'Lorem \n::: spoiler Spoiler\nipsum\n:::\n dolor sit amet consectetur adipiscing elit.'); + expect(formattedText.cursorIndex, 26, reason: "\n::: spoiler Spoiler\nipsum = 26"); + }); + + test('successfully converts to spoiler (partial with start index in the middle) (RTL)', () { + String testString = 'Lorem ipsum dolor sit amet consectetur adipiscing elit.'; + int from = 11; + int to = 6; + + ResultMarkdown formattedText = FormatMarkdown.convertToMarkdown( + MarkdownType.spoiler, + testString, + from, + to, + ); + + expect(formattedText.data, 'Lorem \n::: spoiler Spoiler\nipsum\n:::\n dolor sit amet consectetur adipiscing elit.'); + expect(formattedText.cursorIndex, 26, reason: "\n::: spoiler Spoiler\nipsum = 26"); + }); + + test('successfully converts to spoiler (partial with end index at the end) (LTR)', () { + String testString = 'Lorem ipsum dolor sit amet consectetur adipiscing elit.'; + int from = 6; + int to = 55; + + ResultMarkdown formattedText = FormatMarkdown.convertToMarkdown( + MarkdownType.spoiler, + testString, + from, + to, + ); + + expect(formattedText.data, 'Lorem \n::: spoiler Spoiler\nipsum dolor sit amet consectetur adipiscing elit.\n:::'); + expect(formattedText.cursorIndex, 70, reason: "\n::: spoiler Spoiler\nipsum dolor sit amet consectetur adipiscing elit. = 70"); + }); + + test('successfully converts to spoiler (partial with end index at the end) (RTL)', () { + String testString = 'Lorem ipsum dolor sit amet consectetur adipiscing elit.'; + int from = 55; + int to = 6; + + ResultMarkdown formattedText = FormatMarkdown.convertToMarkdown( + MarkdownType.spoiler, + testString, + from, + to, + ); + + expect(formattedText.data, 'Lorem \n::: spoiler Spoiler\nipsum dolor sit amet consectetur adipiscing elit.\n:::'); + expect(formattedText.cursorIndex, 70, reason: "\n::: spoiler Spoiler\nipsum dolor sit amet consectetur adipiscing elit. = 70"); + }); + + // TODO: Add tests for username, community } diff --git a/test/markdown_text_input_test.dart b/test/markdown_text_input_test.dart index 66c3865..b3cd213 100644 --- a/test/markdown_text_input_test.dart +++ b/test/markdown_text_input_test.dart @@ -1,49 +1,65 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; - -import 'package:markdown_editor/src/format_markdown.dart'; -import 'package:markdown_editor/src/markdown_toolbar.dart'; +import 'package:markdown_editor/markdown_editor.dart'; void main() { + Key boldKey = Key(MarkdownType.bold.key); + Key italicKey = Key(MarkdownType.italic.key); + Key strikethroughKey = Key(MarkdownType.strikethrough.key); + Key linkKey = Key(MarkdownType.link.key); + + Key hKey = Key(MarkdownType.title.key); + Key h1Key = const Key('H1_button'); + Key h2Key = const Key('H2_button'); + Key h3Key = const Key('H3_button'); + Key h4Key = const Key('H4_button'); + Key h5Key = const Key('H5_button'); + Key h6Key = const Key('H6_button'); + + Key listKey = Key(MarkdownType.list.key); + Key codeKey = Key(MarkdownType.code.key); + Key quoteKey = Key(MarkdownType.blockquote.key); + Key separatorKey = Key(MarkdownType.separator.key); + Key imageKey = Key(MarkdownType.image.key); + + Key usernameKey = Key(MarkdownType.username.key); + Key communityKey = Key(MarkdownType.community.key); + Key spoilerKey = Key(MarkdownType.spoiler.key); + Widget component(String initialValue, Function updateValue) { TextEditingController controller = TextEditingController(text: initialValue); FocusNode focusNode = FocusNode(); return MaterialApp( home: Scaffold( - body: MarkdownToolbar( - controller: controller, - focusNode: focusNode, - actions: MarkdownType.values, - insertLinksByDialog: false, + body: Column( + children: [ + MarkdownTextInputField( + controller: controller, + focusNode: focusNode, + spellCheckConfiguration: const SpellCheckConfiguration.disabled(), + ), + MarkdownToolbar( + controller: controller, + focusNode: focusNode, + actions: MarkdownType.values, + insertLinksByDialog: false, + ), + ], ), ), ); } - testWidgets('MarkdownTextInput has all buttons', (WidgetTester tester) async { + testWidgets('MarkdownToolbar has all markdown buttons', (WidgetTester tester) async { await tester.pumpWidget(component('initial value', () => null)); - const boldKey = Key('bold_button'); - const italicKey = Key('italic_button'); - const strikethroughKey = Key('strikethrough_button'); - const hKey = Key('H#_button'); - const h1Key = Key('H1_button'); - const h2Key = Key('H2_button'); - const h3Key = Key('H3_button'); - const h4Key = Key('H4_button'); - const h5Key = Key('H5_button'); - const h6Key = Key('H6_button'); - const linkKey = Key('link_button'); - const listKey = Key('list_button'); - const quoteKey = Key('quote_button'); - const separatorKey = Key('separator_button'); - const imageKey = Key('image_button'); - expect(find.byKey(boldKey), findsOneWidget); expect(find.byKey(italicKey), findsOneWidget); expect(find.byKey(strikethroughKey), findsOneWidget); + expect(find.byKey(linkKey), findsOneWidget); + expect(find.byKey(hKey), findsOneWidget); expect(find.byKey(h1Key), findsOneWidget); expect(find.byKey(h2Key), findsOneWidget); @@ -51,127 +67,217 @@ void main() { expect(find.byKey(h4Key), findsOneWidget); expect(find.byKey(h5Key), findsOneWidget); expect(find.byKey(h6Key), findsOneWidget); - expect(find.byKey(linkKey), findsOneWidget); + expect(find.byKey(listKey), findsOneWidget); + expect(find.byKey(codeKey), findsOneWidget); expect(find.byKey(quoteKey), findsOneWidget); expect(find.byKey(separatorKey), findsOneWidget); expect(find.byKey(imageKey), findsOneWidget); + + expect(find.byKey(usernameKey), findsOneWidget); + expect(find.byKey(communityKey), findsOneWidget); + expect(find.byKey(spoilerKey), findsOneWidget); }); - testWidgets('MarkdownTextInput make bold from selection', (WidgetTester tester) async { + testWidgets('MarkdownTextInputField make bold from selection', (WidgetTester tester) async { String initialValue = 'initial value'; await tester.pumpWidget(component(initialValue, (String value) => initialValue = value)); final formfield = tester.widget(find.text(initialValue)); formfield.controller.selection = TextSelection(baseOffset: 0, extentOffset: initialValue.length); - const boldKey = Key('bold_button'); await tester.tap(find.byKey(boldKey)); + expect(formfield.controller.text, '**initial value**'); + }); - expect(initialValue, '**initial value**'); + testWidgets('MarkdownTextInputField make italic from selection', (WidgetTester tester) async { + String initialValue = 'initial value'; + await tester.pumpWidget(component(initialValue, (String value) => initialValue = value)); + + final formfield = tester.widget(find.text(initialValue)); + formfield.controller.selection = TextSelection(baseOffset: 0, extentOffset: initialValue.length); + + await tester.tap(find.byKey(italicKey)); + expect(formfield.controller.text, '_initial value_'); }); - testWidgets('MarkdownTextInput make italic from selection', (WidgetTester tester) async { + testWidgets('MarkdownTextInputField make strikethrough from selection', (WidgetTester tester) async { String initialValue = 'initial value'; await tester.pumpWidget(component(initialValue, (String value) => initialValue = value)); final formfield = tester.widget(find.text(initialValue)); formfield.controller.selection = TextSelection(baseOffset: 0, extentOffset: initialValue.length); - const boldKey = Key('italic_button'); - await tester.tap(find.byKey(boldKey)); + await tester.tap(find.byKey(strikethroughKey)); + expect(formfield.controller.text, '~~initial value~~'); + }); - expect(initialValue, '_initial value_'); + testWidgets('MarkdownTextInputField make link from selection', (WidgetTester tester) async { + String initialValue = 'initial value'; + await tester.pumpWidget(component(initialValue, (String value) => initialValue = value)); + + final formfield = tester.widget(find.text(initialValue)); + formfield.controller.selection = TextSelection(baseOffset: 0, extentOffset: initialValue.length); + + await tester.tap(find.byKey(linkKey)); + expect(formfield.controller.text, '[initial value](initial value)'); }); - testWidgets('MarkdownTextInput make strikethrough from selection', (WidgetTester tester) async { + testWidgets('MarkdownTextInputField make header 1 from selection', (WidgetTester tester) async { String initialValue = 'initial value'; await tester.pumpWidget(component(initialValue, (String value) => initialValue = value)); final formfield = tester.widget(find.text(initialValue)); formfield.controller.selection = TextSelection(baseOffset: 0, extentOffset: initialValue.length); - const boldKey = Key('strikethrough_button'); - await tester.tap(find.byKey(boldKey)); + await tester.tap(find.byKey(hKey)); + await tester.ensureVisible(find.byKey(h1Key)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(h1Key)); - expect(initialValue, '~~initial value~~'); + expect(formfield.controller.text, '# initial value'); }); - testWidgets('MarkdownTextInput make code from selection', (WidgetTester tester) async { + testWidgets('MarkdownTextInputField make header 2 from selection', (WidgetTester tester) async { String initialValue = 'initial value'; await tester.pumpWidget(component(initialValue, (String value) => initialValue = value)); final formfield = tester.widget(find.text(initialValue)); formfield.controller.selection = TextSelection(baseOffset: 0, extentOffset: initialValue.length); - const boldKey = Key('code_button'); - await tester.tap(find.byKey(boldKey)); + await tester.tap(find.byKey(hKey)); + await tester.ensureVisible(find.byKey(h2Key)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(h2Key)); - expect(initialValue, '```initial value```'); + expect(formfield.controller.text, '## initial value'); }); - testWidgets('MarkdownTextInput make link from selection', (WidgetTester tester) async { + testWidgets('MarkdownTextInputField make header 3 from selection', (WidgetTester tester) async { String initialValue = 'initial value'; await tester.pumpWidget(component(initialValue, (String value) => initialValue = value)); final formfield = tester.widget(find.text(initialValue)); formfield.controller.selection = TextSelection(baseOffset: 0, extentOffset: initialValue.length); - const boldKey = Key('link_button'); - await tester.tap(find.byKey(boldKey)); + await tester.tap(find.byKey(hKey)); + await tester.ensureVisible(find.byKey(h3Key)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(h3Key)); - expect(initialValue, '[initial value](initial value)'); + expect(formfield.controller.text, '### initial value'); }); - testWidgets('MarkdownTextInput make list from selection', (WidgetTester tester) async { - String initialValue = 'initial\nvalue'; + testWidgets('MarkdownTextInputField make header 4 from selection', (WidgetTester tester) async { + String initialValue = 'initial value'; await tester.pumpWidget(component(initialValue, (String value) => initialValue = value)); final formfield = tester.widget(find.text(initialValue)); formfield.controller.selection = TextSelection(baseOffset: 0, extentOffset: initialValue.length); - const boldKey = Key('list_button'); - await tester.tap(find.byKey(boldKey)); + await tester.tap(find.byKey(hKey)); + await tester.ensureVisible(find.byKey(h4Key)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(h4Key)); - expect(initialValue, '* initial\n* value'); + expect(formfield.controller.text, '#### initial value'); }); - testWidgets('MarkdownTextInput make blockquote from selection', (WidgetTester tester) async { - String initialValue = 'initial\nvalue'; + testWidgets('MarkdownTextInputField make header 5 from selection', (WidgetTester tester) async { + String initialValue = 'initial value'; await tester.pumpWidget(component(initialValue, (String value) => initialValue = value)); final formfield = tester.widget(find.text(initialValue)); formfield.controller.selection = TextSelection(baseOffset: 0, extentOffset: initialValue.length); - const boldKey = Key('quote_button'); - await tester.tap(find.byKey(boldKey)); + await tester.tap(find.byKey(hKey)); + await tester.ensureVisible(find.byKey(h5Key)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(h5Key)); - expect(initialValue, '> initial\n> value'); + expect(formfield.controller.text, '##### initial value'); }); - testWidgets('MarkdownTextInput make separator from selection', (WidgetTester tester) async { + testWidgets('MarkdownTextInputField make header 6 from selection', (WidgetTester tester) async { String initialValue = 'initial value'; await tester.pumpWidget(component(initialValue, (String value) => initialValue = value)); final formfield = tester.widget(find.text(initialValue)); formfield.controller.selection = TextSelection(baseOffset: 0, extentOffset: initialValue.length); - const boldKey = Key('separator_button'); - await tester.tap(find.byKey(boldKey)); + await tester.tap(find.byKey(hKey)); + await tester.ensureVisible(find.byKey(h6Key)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(h6Key)); + + expect(formfield.controller.text, '###### initial value'); + }); + + testWidgets('MarkdownTextInputField make list from selection', (WidgetTester tester) async { + String initialValue = 'initial\nvalue'; + await tester.pumpWidget(component(initialValue, (String value) => initialValue = value)); - expect(initialValue, '\n------\ninitial value'); + final formfield = tester.widget(find.text(initialValue)); + formfield.controller.selection = TextSelection(baseOffset: 0, extentOffset: initialValue.length); + + await tester.tap(find.byKey(listKey)); + expect(formfield.controller.text, '* initial\n* value'); }); - testWidgets('MarkdownTextInput make image link from selection', (WidgetTester tester) async { + testWidgets('MarkdownTextInputField make code from selection', (WidgetTester tester) async { String initialValue = 'initial value'; await tester.pumpWidget(component(initialValue, (String value) => initialValue = value)); final formfield = tester.widget(find.text(initialValue)); formfield.controller.selection = TextSelection(baseOffset: 0, extentOffset: initialValue.length); - const boldKey = Key('image_button'); - await tester.tap(find.byKey(boldKey)); + await tester.tap(find.byKey(codeKey)); + expect(formfield.controller.text, '```initial value```'); + }); - expect(initialValue, '![initial value](initial value)'); + testWidgets('MarkdownTextInputField make blockquote from selection', (WidgetTester tester) async { + String initialValue = 'initial\nvalue'; + await tester.pumpWidget(component(initialValue, (String value) => initialValue = value)); + + final formfield = tester.widget(find.text(initialValue)); + formfield.controller.selection = TextSelection(baseOffset: 0, extentOffset: initialValue.length); + + await tester.tap(find.byKey(quoteKey)); + expect(formfield.controller.text, '> initial\n> value'); }); + + testWidgets('MarkdownTextInputField make separator from selection', (WidgetTester tester) async { + String initialValue = 'initial value'; + await tester.pumpWidget(component(initialValue, (String value) => initialValue = value)); + + final formfield = tester.widget(find.text(initialValue)); + formfield.controller.selection = TextSelection(baseOffset: 0, extentOffset: initialValue.length); + + await tester.tap(find.byKey(separatorKey)); + expect(formfield.controller.text, '\n------\ninitial value'); + }); + + testWidgets('MarkdownTextInputField make image link from selection', (WidgetTester tester) async { + String initialValue = 'initial value'; + await tester.pumpWidget(component(initialValue, (String value) => initialValue = value)); + + final formfield = tester.widget(find.text(initialValue)); + formfield.controller.selection = TextSelection(baseOffset: 0, extentOffset: initialValue.length); + + await tester.tap(find.byKey(imageKey)); + expect(formfield.controller.text, '![initial value](initial value)'); + }); + + testWidgets('MarkdownTextInputField make spoiler from selection', (WidgetTester tester) async { + String initialValue = 'initial value'; + await tester.pumpWidget(component(initialValue, (String value) => initialValue = value)); + + final formfield = tester.widget(find.text(initialValue)); + formfield.controller.selection = TextSelection(baseOffset: 0, extentOffset: initialValue.length); + + await tester.tap(find.byKey(spoilerKey)); + expect(formfield.controller.text, '::: spoiler Spoiler\ninitial value\n:::'); + }); + + // TODO: Add tests for username, community buttons }