diff options
author | Stefan Niedermann <info@niedermann.it> | 2021-06-13 16:20:29 +0300 |
---|---|---|
committer | Stefan Niedermann <info@niedermann.it> | 2021-06-13 16:20:29 +0300 |
commit | f57603d4726b09573c29cc825e6d963674ef7a44 (patch) | |
tree | 455936e24fe4220ed38ebcede738336e723fcadb | |
parent | e1b6e8b785ef2c7ab7c3105ff856805a84a03cca (diff) |
Refactor links in checkboxes and add unit tests
Signed-off-by: Stefan Niedermann <info@niedermann.it>
3 files changed, 253 insertions, 36 deletions
diff --git a/markdown/build.gradle b/markdown/build.gradle index 91d8d437..a9107750 100644 --- a/markdown/build.gradle +++ b/markdown/build.gradle @@ -57,6 +57,9 @@ dependencies { coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' + testImplementation 'androidx.test:core:1.3.0' + testImplementation 'androidx.arch.core:core-testing:2.1.0' testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:3.11.0' testImplementation 'org.robolectric:robolectric:4.5.1' }
\ No newline at end of file diff --git a/markdown/src/main/java/it/niedermann/android/markdown/markwon/plugins/ToggleableTaskListPlugin.java b/markdown/src/main/java/it/niedermann/android/markdown/markwon/plugins/ToggleableTaskListPlugin.java index b43c5711..77411392 100644 --- a/markdown/src/main/java/it/niedermann/android/markdown/markwon/plugins/ToggleableTaskListPlugin.java +++ b/markdown/src/main/java/it/niedermann/android/markdown/markwon/plugins/ToggleableTaskListPlugin.java @@ -3,8 +3,10 @@ package it.niedermann.android.markdown.markwon.plugins; import android.text.Spannable; import android.text.style.ClickableSpan; import android.util.Range; +import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; import org.commonmark.node.AbstractVisitor; import org.commonmark.node.Block; @@ -15,7 +17,6 @@ import org.commonmark.node.SoftLineBreak; import org.commonmark.node.Text; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; @@ -29,6 +30,7 @@ import io.noties.markwon.SpannableBuilder; import io.noties.markwon.ext.tasklist.TaskListItem; import io.noties.markwon.ext.tasklist.TaskListProps; import io.noties.markwon.ext.tasklist.TaskListSpan; +import it.niedermann.android.markdown.MarkdownUtil; import it.niedermann.android.markdown.markwon.span.ToggleTaskListSpan; /** @@ -103,81 +105,92 @@ public class ToggleableTaskListPlugin extends AbstractMarkwonPlugin { }); } + + /** + * Adds for each {@link ToggleMarkerSpan} and actual {@link ToggleTaskListSpan}s respecting existing {@link ClickableSpan}s. + */ @Override public void afterRender(@NonNull Node node, @NonNull MarkwonVisitor visitor) { super.afterRender(node, visitor); - final Spannable spanned = visitor.builder().spannableStringBuilder(); - final List<ToggleMarkerSpan> markerSpans = getSortedToggleMarkerSpans(spanned, visitor.builder()); + final List<SpannableBuilder.Span> markerSpans = getSortedToggleMarkerSpans(visitor.builder()); - replaceMarkerSpans(markerSpans, spanned, visitor.builder()); - } - - /** - * Converts all {@link ToggleMarkerSpan}s to actual {@link ToggleTaskListSpan}s respecting existing {@link ClickableSpan}s. - */ - private void replaceMarkerSpans(@NonNull List<ToggleMarkerSpan> markerSpans, @NonNull Spannable spanned, @NonNull SpannableBuilder builder) { for (int position = 0; position < markerSpans.size(); position++) { - final ToggleMarkerSpan markerSpan = markerSpans.get(position); - final int start = spanned.getSpanStart(markerSpan); - final int end = spanned.getSpanEnd(markerSpan); - final List<Range<Integer>> freeRanges = findFreeRanges(spanned, start, end); + final SpannableBuilder.Span markerSpan = markerSpans.get(position); + final int start = markerSpan.start; + final int end = markerSpan.end; + final List<Range<Integer>> freeRanges = findFreeRanges(visitor.builder(), start, end); for (Range<Integer> freeRange : freeRanges) { - builder.setSpan( - new ToggleTaskListSpan(enabled, toggleListener, markerSpan.getTaskListSpan(), position), + visitor.builder().setSpan( + new ToggleTaskListSpan(enabled, toggleListener, ((ToggleMarkerSpan) markerSpan.what).getTaskListSpan(), position), freeRange.getLower(), freeRange.getUpper()); } - spanned.removeSpan(markerSpan); } } /** - * @return a {@link List} of {@link ToggleMarkerSpan}s, sorted ascending by the span start. + * Removes {@link ToggleMarkerSpan}s from {@param textView}. */ - @NonNull - private static List<ToggleMarkerSpan> getSortedToggleMarkerSpans(@NonNull Spannable spanned, @NonNull SpannableBuilder builder) { - return builder.getSpans(0, builder.length()) - .stream() - .filter(span -> span.what instanceof ToggleMarkerSpan) - .map(span -> ((ToggleMarkerSpan) span.what)) - .sorted((o1, o2) -> spanned.getSpanStart(o1) - spanned.getSpanStart(o2)) - .collect(Collectors.toList()); + @Override + public void afterSetText(@NonNull TextView textView) { + super.afterSetText(textView); + final Spannable spannable = MarkdownUtil.getContentAsSpannable(textView); + for (ToggleMarkerSpan span : spannable.getSpans(0, spannable.length(), ToggleMarkerSpan.class)) { + spannable.removeSpan(span); + } + textView.setText(spannable); } /** * @return a {@link List} of {@link Range}s in the given {@param spanned} from {@param start} to {@param end} which is <strong>not</strong> taken for a {@link ClickableSpan}. */ @NonNull - private static List<Range<Integer>> findFreeRanges(@NonNull Spannable spanned, int start, int end) { + private static List<Range<Integer>> findFreeRanges(@NonNull SpannableBuilder builder, int start, int end) { final List<Range<Integer>> freeRanges; - final List<ClickableSpan> clickableSpans = getClickableSpans(spanned, start, end); + final List<SpannableBuilder.Span> clickableSpans = getClickableSpans(builder, start, end); if (clickableSpans.size() > 0) { freeRanges = new ArrayList<>(clickableSpans.size()); int from = start; - for (ClickableSpan clickableSpan : clickableSpans) { - final int clickableStart = spanned.getSpanStart(clickableSpan); - final int clickableEnd = spanned.getSpanEnd(clickableSpan); + for (SpannableBuilder.Span clickableSpan : clickableSpans) { + final int clickableStart = clickableSpan.start; + final int clickableEnd = clickableSpan.end; if (from != clickableStart) { freeRanges.add(new Range<>(from, clickableStart)); } from = clickableEnd; } if (clickableSpans.size() > 0) { - final int lastUpperBlocker = spanned.getSpanEnd(clickableSpans.get(clickableSpans.size() - 1)); + final int lastUpperBlocker = clickableSpans.get(clickableSpans.size() - 1).end; if (lastUpperBlocker < end) { freeRanges.add(new Range<>(lastUpperBlocker, end)); } } + } else if (start == end) { + freeRanges = Collections.emptyList(); } else { freeRanges = Collections.singletonList(new Range<>(start, end)); } return freeRanges; } + /** + * @return a {@link List} of {@link ToggleMarkerSpan}s, sorted ascending by the span start. + */ @NonNull - private static List<ClickableSpan> getClickableSpans(@NonNull Spannable spanned, int start, int end) { - return Arrays.stream(spanned.getSpans(start, end, ClickableSpan.class)) - .sorted((o1, o2) -> spanned.getSpanStart(o1) - spanned.getSpanStart(o2)) + private static List<SpannableBuilder.Span> getSortedToggleMarkerSpans(@NonNull SpannableBuilder builder) { + return builder.getSpans(0, builder.length()) + .stream() + .filter(span -> span.what instanceof ToggleMarkerSpan) + .sorted((o1, o2) -> o1.start - o2.start) + .collect(Collectors.toList()); + } + + @NonNull + private static List<SpannableBuilder.Span> getClickableSpans(@NonNull SpannableBuilder builder, int start, int end) { + return builder.getSpans(start, end) + .stream() + .filter(span -> span.what instanceof ClickableSpan) + .sorted((o1, o2) -> o1.start - o2.start) .collect(Collectors.toList()); } @@ -229,7 +242,8 @@ public class ToggleableTaskListPlugin extends AbstractMarkwonPlugin { /** * Helper class which holds an {@link TaskListSpan} but does not include the range of child {@link TaskListSpan}s. */ - private static final class ToggleMarkerSpan { + @VisibleForTesting + static final class ToggleMarkerSpan { @NonNull private final TaskListSpan taskListSpan; diff --git a/markdown/src/test/java/it/niedermann/android/markdown/markwon/plugins/ToggleableTaskListPluginTest.java b/markdown/src/test/java/it/niedermann/android/markdown/markwon/plugins/ToggleableTaskListPluginTest.java new file mode 100644 index 00000000..823e1e13 --- /dev/null +++ b/markdown/src/test/java/it/niedermann/android/markdown/markwon/plugins/ToggleableTaskListPluginTest.java @@ -0,0 +1,200 @@ +package it.niedermann.android.markdown.markwon.plugins; + +import android.text.Editable; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.ClickableSpan; +import android.text.style.URLSpan; +import android.util.Range; +import android.widget.TextView; + +import androidx.test.core.app.ApplicationProvider; + +import junit.framework.TestCase; + +import org.commonmark.node.Node; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.SpannableBuilder; +import io.noties.markwon.ext.tasklist.TaskListSpan; +import it.niedermann.android.markdown.markwon.span.InterceptedURLSpan; +import it.niedermann.android.markdown.markwon.span.ToggleTaskListSpan; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +public class ToggleableTaskListPluginTest extends TestCase { + + @Test + public void testAfterRender() throws IllegalAccessException, InvocationTargetException, InstantiationException, NoSuchMethodException { + final Node node = mock(Node.class); + final MarkwonVisitor visitor = mock(MarkwonVisitor.class); + + final Constructor<ToggleableTaskListPlugin.ToggleMarkerSpan> markerSpanConstructor = ToggleableTaskListPlugin.ToggleMarkerSpan.class.getDeclaredConstructor(TaskListSpan.class); + markerSpanConstructor.setAccessible(true); + + final SpannableBuilder builder = new SpannableBuilder("Lorem Ipsum Dolor \nSit Amet"); + builder.setSpan(markerSpanConstructor.newInstance(mock(TaskListSpan.class)), 0, 6); + builder.setSpan(new URLSpan(""), 6, 11); + builder.setSpan(markerSpanConstructor.newInstance(mock(TaskListSpan.class)), 11, 19); + builder.setSpan(new InterceptedURLSpan(Collections.emptyList(), ""), 19, 22); + builder.setSpan(markerSpanConstructor.newInstance(mock(TaskListSpan.class)), 22, 27); + + when(visitor.builder()).thenReturn(builder); + + final ToggleableTaskListPlugin plugin = new ToggleableTaskListPlugin((i, b) -> { + // Do nothing... + }); + plugin.afterRender(node, visitor); + + // We ignore marker spans in this test. They will be removed in another step + final List<SpannableBuilder.Span> spans = builder.getSpans(0, builder.length()) + .stream() + .filter(span -> span.what.getClass() != ToggleableTaskListPlugin.ToggleMarkerSpan.class) + .sorted((o1, o2) -> o1.start - o2.start) + .collect(Collectors.toList()); + + assertEquals(5, spans.size()); + assertEquals(ToggleTaskListSpan.class, spans.get(0).what.getClass()); + assertEquals(0, spans.get(0).start); + assertEquals(6, spans.get(0).end); + assertEquals(URLSpan.class, spans.get(1).what.getClass()); + assertEquals(6, spans.get(1).start); + assertEquals(11, spans.get(1).end); + assertEquals(ToggleTaskListSpan.class, spans.get(2).what.getClass()); + assertEquals(11, spans.get(2).start); + assertEquals(19, spans.get(2).end); + assertEquals(InterceptedURLSpan.class, spans.get(3).what.getClass()); + assertEquals(19, spans.get(3).start); + assertEquals(22, spans.get(3).end); + assertEquals(ToggleTaskListSpan.class, spans.get(4).what.getClass()); + assertEquals(22, spans.get(4).start); + assertEquals(27, spans.get(4).end); + } + + @Test + public void testAfterSetText() throws IllegalAccessException, InvocationTargetException, InstantiationException, NoSuchMethodException { + final Constructor<ToggleableTaskListPlugin.ToggleMarkerSpan> markerSpanConstructor = ToggleableTaskListPlugin.ToggleMarkerSpan.class.getDeclaredConstructor(TaskListSpan.class); + markerSpanConstructor.setAccessible(true); + + final Editable editable = new SpannableStringBuilder("Lorem Ipsum Dolor \nSit Amet"); + editable.setSpan(markerSpanConstructor.newInstance(mock(TaskListSpan.class)), 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + editable.setSpan(new URLSpan(""), 6, 11, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + editable.setSpan(markerSpanConstructor.newInstance(mock(TaskListSpan.class)), 11, 19, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + editable.setSpan(new InterceptedURLSpan(Collections.emptyList(), ""), 19, 22, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + editable.setSpan(markerSpanConstructor.newInstance(mock(TaskListSpan.class)), 22, 27, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + final TextView textView = new TextView(ApplicationProvider.getApplicationContext()); + textView.setText(editable); + + assertEquals(3, ((Spanned) textView.getText()).getSpans(0, textView.getText().length(), ToggleableTaskListPlugin.ToggleMarkerSpan.class).length); + + final ToggleableTaskListPlugin plugin = new ToggleableTaskListPlugin((i, b) -> { + // Do nothing... + }); + plugin.afterSetText(textView); + + assertEquals(0, ((Spanned) textView.getText()).getSpans(0, textView.getText().length(), ToggleableTaskListPlugin.ToggleMarkerSpan.class).length); + } + + @Test + @SuppressWarnings({"unchecked", "ConstantConditions"}) + public void testGetClickableSpans() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + final Method m = ToggleableTaskListPlugin.class.getDeclaredMethod("getClickableSpans", SpannableBuilder.class, int.class, int.class); + m.setAccessible(true); + + final Object firstClickableSpan = new URLSpan(""); + final Object secondClickableSpan = new InterceptedURLSpan(Collections.emptyList(), ""); + final SpannableBuilder spannable = new SpannableBuilder("Lorem Ipsum Dolor \nSit Amet"); + spannable.setSpan(firstClickableSpan, 6, 11, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + spannable.setSpan(secondClickableSpan, 19, 22, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + List<SpannableBuilder.Span> clickableSpans; + + clickableSpans = (List<SpannableBuilder.Span>) m.invoke(null, spannable, 0, 0); + assertEquals(0, clickableSpans.size()); + + clickableSpans = (List<SpannableBuilder.Span>) m.invoke(null, spannable, spannable.length() - 1, spannable.length() - 1); + assertEquals(0, clickableSpans.size()); + + clickableSpans = (List<SpannableBuilder.Span>) m.invoke(null, spannable, 0, 5); + assertEquals(0, clickableSpans.size()); + + clickableSpans = (List<SpannableBuilder.Span>) m.invoke(null, spannable, 0, spannable.length()); + assertEquals(2, clickableSpans.size()); + assertEquals(firstClickableSpan, clickableSpans.get(0).what); + assertEquals(secondClickableSpan, clickableSpans.get(1).what); + + clickableSpans = (List<SpannableBuilder.Span>) m.invoke(null, spannable, 0, 17); + assertEquals(1, clickableSpans.size()); + assertEquals(firstClickableSpan, clickableSpans.get(0).what); + + clickableSpans = (List<SpannableBuilder.Span>) m.invoke(null, spannable, 12, 22); + assertEquals(1, clickableSpans.size()); + assertEquals(secondClickableSpan, clickableSpans.get(0).what); + + clickableSpans = (List<SpannableBuilder.Span>) m.invoke(null, spannable, 9, 20); + assertEquals(2, clickableSpans.size()); + assertEquals(firstClickableSpan, clickableSpans.get(0).what); + assertEquals(secondClickableSpan, clickableSpans.get(1).what); + } + + @Test + @SuppressWarnings({"unchecked", "ConstantConditions"}) + public void testFindFreeRanges() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + final Method m = ToggleableTaskListPlugin.class.getDeclaredMethod("findFreeRanges", SpannableBuilder.class, int.class, int.class); + m.setAccessible(true); + + final Object firstClickableSpan = new URLSpan(""); + final Object secondClickableSpan = new InterceptedURLSpan(Collections.emptyList(), ""); + final SpannableBuilder spannable = new SpannableBuilder("Lorem Ipsum Dolor \nSit Amet"); + spannable.setSpan(firstClickableSpan, 6, 11, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + spannable.setSpan(secondClickableSpan, 19, 22, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + List<Range<Integer>> freeRanges; + + freeRanges = (List<Range<Integer>>) m.invoke(null, spannable, 0, 0); + assertEquals(0, freeRanges.size()); + + freeRanges = (List<Range<Integer>>) m.invoke(null, spannable, spannable.length() - 1, spannable.length() - 1); + assertEquals(0, freeRanges.size()); + + freeRanges = (List<Range<Integer>>) m.invoke(null, spannable, 0, 6); + assertEquals(1, freeRanges.size()); + assertEquals(0, (int) freeRanges.get(0).getLower()); + assertEquals(6, (int) freeRanges.get(0).getUpper()); + + freeRanges = (List<Range<Integer>>) m.invoke(null, spannable, 0, 6); + assertEquals(1, freeRanges.size()); + assertEquals(0, (int) freeRanges.get(0).getLower()); + assertEquals(6, (int) freeRanges.get(0).getUpper()); + + freeRanges = (List<Range<Integer>>) m.invoke(null, spannable, 3, 15); + assertEquals(2, freeRanges.size()); + assertEquals(3, (int) freeRanges.get(0).getLower()); + assertEquals(6, (int) freeRanges.get(0).getUpper()); + assertEquals(11, (int) freeRanges.get(1).getLower()); + assertEquals(15, (int) freeRanges.get(1).getUpper()); + + freeRanges = (List<Range<Integer>>) m.invoke(null, spannable, 0, spannable.length()); + assertEquals(3, freeRanges.size()); + assertEquals(0, (int) freeRanges.get(0).getLower()); + assertEquals(6, (int) freeRanges.get(0).getUpper()); + assertEquals(11, (int) freeRanges.get(1).getLower()); + assertEquals(19, (int) freeRanges.get(1).getUpper()); + assertEquals(22, (int) freeRanges.get(2).getLower()); + assertEquals(27, (int) freeRanges.get(2).getUpper()); + } +} |