From 6bf04e38ad1754f91c233c02dc366848bde9c7e0 Mon Sep 17 00:00:00 2001
From: Dimitry Ivanov <di@noties.io>
Date: Mon, 3 Jun 2019 15:28:52 +0300
Subject: [PATCH] Add tests for markwon-image module

---
 .../ru/noties/markwon/core/CorePlugin.java    |   9 +-
 .../markwon/image/AsyncDrawableLoader.java    |   2 -
 .../image/AsyncDrawableLoaderNoOp.java        |   2 +-
 .../markwon/ext/latex/JLatexMathPlugin.java   |   1 +
 .../image/AsyncDrawableLoaderBuilder.java     |  19 +-
 .../ru/noties/markwon/image/ImagesPlugin.java |  29 ++-
 .../image/AsyncDrawableLoaderBuilderTest.java | 194 ++++++++++++++++++
 .../markwon/image/ImagesPluginTest.java       | 177 ++++++++++++++++
 markwon-linkify/README.md                     |   5 +
 .../noties/markwon/linkify/LinkifyPlugin.java |   3 +-
 10 files changed, 425 insertions(+), 16 deletions(-)
 create mode 100644 markwon-image/src/test/java/ru/noties/markwon/image/AsyncDrawableLoaderBuilderTest.java
 create mode 100644 markwon-image/src/test/java/ru/noties/markwon/image/ImagesPluginTest.java
 create mode 100644 markwon-linkify/README.md

diff --git a/markwon-core/src/main/java/ru/noties/markwon/core/CorePlugin.java b/markwon-core/src/main/java/ru/noties/markwon/core/CorePlugin.java
index 96d8bb11..506da2b9 100644
--- a/markwon-core/src/main/java/ru/noties/markwon/core/CorePlugin.java
+++ b/markwon-core/src/main/java/ru/noties/markwon/core/CorePlugin.java
@@ -165,14 +165,17 @@ public class CorePlugin extends AbstractMarkwonPlugin {
             @Override
             public void visit(@NonNull MarkwonVisitor visitor, @NonNull Text text) {
 
-                final int length = visitor.length();
                 final String literal = text.getLiteral();
 
                 visitor.builder().append(literal);
 
                 // @since 4.0.0-SNAPSHOT
-                for (OnTextAddedListener onTextAddedListener : onTextAddedListeners) {
-                    onTextAddedListener.onTextAdded(visitor, literal, length);
+                if (!onTextAddedListeners.isEmpty()) {
+                    // calculate the start position
+                    final int length = visitor.length() - literal.length();
+                    for (OnTextAddedListener onTextAddedListener : onTextAddedListeners) {
+                        onTextAddedListener.onTextAdded(visitor, literal, length);
+                    }
                 }
             }
         });
diff --git a/markwon-core/src/main/java/ru/noties/markwon/image/AsyncDrawableLoader.java b/markwon-core/src/main/java/ru/noties/markwon/image/AsyncDrawableLoader.java
index f9bbb4b7..314e537f 100644
--- a/markwon-core/src/main/java/ru/noties/markwon/image/AsyncDrawableLoader.java
+++ b/markwon-core/src/main/java/ru/noties/markwon/image/AsyncDrawableLoader.java
@@ -27,6 +27,4 @@ public abstract class AsyncDrawableLoader {
     @Nullable
     public abstract Drawable placeholder(@NonNull AsyncDrawable drawable);
 
-
-
 }
diff --git a/markwon-core/src/main/java/ru/noties/markwon/image/AsyncDrawableLoaderNoOp.java b/markwon-core/src/main/java/ru/noties/markwon/image/AsyncDrawableLoaderNoOp.java
index 7c3475b7..e3a6331a 100644
--- a/markwon-core/src/main/java/ru/noties/markwon/image/AsyncDrawableLoaderNoOp.java
+++ b/markwon-core/src/main/java/ru/noties/markwon/image/AsyncDrawableLoaderNoOp.java
@@ -4,7 +4,7 @@ import android.graphics.drawable.Drawable;
 import android.support.annotation.NonNull;
 import android.support.annotation.Nullable;
 
-public class AsyncDrawableLoaderNoOp extends AsyncDrawableLoader {
+class AsyncDrawableLoaderNoOp extends AsyncDrawableLoader {
     @Override
     public void load(@NonNull AsyncDrawable drawable) {
 
diff --git a/markwon-ext-latex/src/main/java/ru/noties/markwon/ext/latex/JLatexMathPlugin.java b/markwon-ext-latex/src/main/java/ru/noties/markwon/ext/latex/JLatexMathPlugin.java
index ee28e04e..decdb6d1 100644
--- a/markwon-ext-latex/src/main/java/ru/noties/markwon/ext/latex/JLatexMathPlugin.java
+++ b/markwon-ext-latex/src/main/java/ru/noties/markwon/ext/latex/JLatexMathPlugin.java
@@ -93,6 +93,7 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin {
 
     private final JLatextAsyncDrawableLoader jLatextAsyncDrawableLoader;
 
+    @SuppressWarnings("WeakerAccess")
     JLatexMathPlugin(@NonNull Config config) {
         this.jLatextAsyncDrawableLoader = new JLatextAsyncDrawableLoader(config);
     }
diff --git a/markwon-image/src/main/java/ru/noties/markwon/image/AsyncDrawableLoaderBuilder.java b/markwon-image/src/main/java/ru/noties/markwon/image/AsyncDrawableLoaderBuilder.java
index e82730f4..57347b70 100644
--- a/markwon-image/src/main/java/ru/noties/markwon/image/AsyncDrawableLoaderBuilder.java
+++ b/markwon-image/src/main/java/ru/noties/markwon/image/AsyncDrawableLoaderBuilder.java
@@ -8,6 +8,9 @@ import java.util.Map;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 
+import ru.noties.markwon.image.data.DataUriSchemeHandler;
+import ru.noties.markwon.image.network.NetworkSchemeHandler;
+
 class AsyncDrawableLoaderBuilder {
 
     ExecutorService executorService;
@@ -19,6 +22,15 @@ class AsyncDrawableLoaderBuilder {
 
     boolean isBuilt;
 
+    AsyncDrawableLoaderBuilder() {
+
+        // @since 4.0.0-SNAPSHOT
+        // okay, let's add supported schemes at the start, this would be : data-uri and default network
+        // we should not use file-scheme as it's a bit complicated to assume file usage (lack of permissions)
+        addSchemeHandler(DataUriSchemeHandler.create());
+        addSchemeHandler(NetworkSchemeHandler.create());
+    }
+
     void executorService(@NonNull ExecutorService executorService) {
         this.executorService = executorService;
     }
@@ -66,12 +78,6 @@ class AsyncDrawableLoaderBuilder {
 
         isBuilt = true;
 
-        // we must have schemeHandlers registered (we will provide
-        // default media decoder if it's absent)
-        if (schemeHandlers.size() == 0) {
-            return new AsyncDrawableLoaderNoOp();
-        }
-
         // @since 4.0.0-SNAPSHOT
         if (defaultMediaDecoder == null) {
             defaultMediaDecoder = DefaultImageMediaDecoder.create();
@@ -83,5 +89,4 @@ class AsyncDrawableLoaderBuilder {
 
         return new AsyncDrawableLoaderImpl(this);
     }
-
 }
diff --git a/markwon-image/src/main/java/ru/noties/markwon/image/ImagesPlugin.java b/markwon-image/src/main/java/ru/noties/markwon/image/ImagesPlugin.java
index 0a7d9446..e5cac9b3 100644
--- a/markwon-image/src/main/java/ru/noties/markwon/image/ImagesPlugin.java
+++ b/markwon-image/src/main/java/ru/noties/markwon/image/ImagesPlugin.java
@@ -14,6 +14,7 @@ import ru.noties.markwon.AbstractMarkwonPlugin;
 import ru.noties.markwon.MarkwonConfiguration;
 import ru.noties.markwon.MarkwonSpansFactory;
 
+@SuppressWarnings({"UnusedReturnValue", "WeakerAccess"})
 public class ImagesPlugin extends AbstractMarkwonPlugin {
 
     /**
@@ -91,6 +92,12 @@ public class ImagesPlugin extends AbstractMarkwonPlugin {
         return this;
     }
 
+    /**
+     * @see DefaultImageMediaDecoder
+     * @see ru.noties.markwon.image.svg.SvgMediaDecoder
+     * @see ru.noties.markwon.image.gif.GifMediaDecoder
+     * @since 4.0.0-SNAPSHOT
+     */
     @NonNull
     public ImagesPlugin addMediaDecoder(@NonNull MediaDecoder mediaDecoder) {
         checkBuilderState();
@@ -98,13 +105,23 @@ public class ImagesPlugin extends AbstractMarkwonPlugin {
         return this;
     }
 
+    /**
+     * Please note that if not specified a {@link DefaultImageMediaDecoder} will be used. So
+     * if you need to disable default-image-media-decoder specify here own no-op implementation.
+     *
+     * @see DefaultImageMediaDecoder
+     * @since 4.0.0-SNAPSHOT
+     */
     @NonNull
-    public ImagesPlugin defaultMediaDecoder(@Nullable MediaDecoder mediaDecoder) {
+    public ImagesPlugin defaultMediaDecoder(@NonNull MediaDecoder mediaDecoder) {
         checkBuilderState();
         builder.defaultMediaDecoder(mediaDecoder);
         return this;
     }
 
+    /**
+     * @since 4.0.0-SNAPSHOT
+     */
     @NonNull
     public ImagesPlugin removeSchemeHandler(@NonNull String scheme) {
         checkBuilderState();
@@ -112,6 +129,9 @@ public class ImagesPlugin extends AbstractMarkwonPlugin {
         return this;
     }
 
+    /**
+     * @since 4.0.0-SNAPSHOT
+     */
     @NonNull
     public ImagesPlugin removeMediaDecoder(@NonNull String contentType) {
         checkBuilderState();
@@ -119,6 +139,9 @@ public class ImagesPlugin extends AbstractMarkwonPlugin {
         return this;
     }
 
+    /**
+     * @since 4.0.0-SNAPSHOT
+     */
     @NonNull
     public ImagesPlugin placeholderProvider(@NonNull PlaceholderProvider placeholderProvider) {
         checkBuilderState();
@@ -126,6 +149,10 @@ public class ImagesPlugin extends AbstractMarkwonPlugin {
         return this;
     }
 
+    /**
+     * @see ErrorHandler
+     * @since 4.0.0-SNAPSHOT
+     */
     @NonNull
     public ImagesPlugin errorHandler(@NonNull ErrorHandler errorHandler) {
         checkBuilderState();
diff --git a/markwon-image/src/test/java/ru/noties/markwon/image/AsyncDrawableLoaderBuilderTest.java b/markwon-image/src/test/java/ru/noties/markwon/image/AsyncDrawableLoaderBuilderTest.java
new file mode 100644
index 00000000..b2400a37
--- /dev/null
+++ b/markwon-image/src/test/java/ru/noties/markwon/image/AsyncDrawableLoaderBuilderTest.java
@@ -0,0 +1,194 @@
+package ru.noties.markwon.image;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.concurrent.ExecutorService;
+
+import ru.noties.markwon.image.data.DataUriSchemeHandler;
+import ru.noties.markwon.image.network.NetworkSchemeHandler;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = Config.NONE)
+public class AsyncDrawableLoaderBuilderTest {
+
+    private AsyncDrawableLoaderBuilder builder;
+
+    @Before
+    public void before() {
+        builder = new AsyncDrawableLoaderBuilder();
+    }
+
+    @Test
+    public void default_scheme_handlers() {
+        // builder adds default data-uri and network scheme-handlers
+
+        final String[] registered = {
+                DataUriSchemeHandler.SCHEME,
+                NetworkSchemeHandler.SCHEME_HTTP,
+                NetworkSchemeHandler.SCHEME_HTTPS
+        };
+
+        for (String scheme : registered) {
+            assertNotNull(scheme, builder.schemeHandlers.get(scheme));
+        }
+    }
+
+    @Test
+    public void built_flag() {
+        // isBuilt flag must be set after `build` method call
+
+        assertFalse(builder.isBuilt);
+
+        builder.build();
+
+        assertTrue(builder.isBuilt);
+    }
+
+    @Test
+    public void defaults_initialized() {
+        // default-media-decoder and executor-service must be initialized
+
+        assertNull(builder.defaultMediaDecoder);
+        assertNull(builder.executorService);
+
+        builder.build();
+
+        assertNotNull(builder.defaultMediaDecoder);
+        assertNotNull(builder.executorService);
+    }
+
+    @Test
+    public void executor() {
+        // supplied executor-service must be used
+
+        assertNull(builder.executorService);
+
+        final ExecutorService service = mock(ExecutorService.class);
+        builder.executorService(service);
+
+        builder.build();
+
+        assertEquals(service, builder.executorService);
+    }
+
+    @Test
+    public void add_scheme_handler() {
+
+        final String scheme = "mock";
+        assertNull(builder.schemeHandlers.get(scheme));
+
+        final SchemeHandler schemeHandler = mock(SchemeHandler.class);
+        when(schemeHandler.supportedSchemes()).thenReturn(Collections.singleton(scheme));
+
+        builder.addSchemeHandler(schemeHandler);
+        builder.build();
+
+        assertEquals(schemeHandler, builder.schemeHandlers.get(scheme));
+    }
+
+    @Test
+    public void add_scheme_handler_multiple_types() {
+        // all supported types are registered
+
+        final String[] schemes = {
+                "mock-1",
+                "mock-2"
+        };
+
+        final SchemeHandler schemeHandler = mock(SchemeHandler.class);
+        when(schemeHandler.supportedSchemes()).thenReturn(Arrays.asList(schemes));
+
+        builder.addSchemeHandler(schemeHandler);
+
+        for (String scheme : schemes) {
+            assertEquals(scheme, schemeHandler, builder.schemeHandlers.get(scheme));
+        }
+    }
+
+    @Test
+    public void add_media_decoder() {
+
+        final String media = "mocked/type";
+        assertNull(builder.mediaDecoders.get(media));
+
+        final MediaDecoder mediaDecoder = mock(MediaDecoder.class);
+        when(mediaDecoder.supportedTypes()).thenReturn(Collections.singleton(media));
+
+        builder.addMediaDecoder(mediaDecoder);
+        builder.build();
+
+        assertEquals(mediaDecoder, builder.mediaDecoders.get(media));
+    }
+
+    @Test
+    public void add_media_decoder_multiple_types() {
+
+        final String[] types = {
+                "mock/type1",
+                "mock/type2"
+        };
+
+        final MediaDecoder mediaDecoder = mock(MediaDecoder.class);
+        when(mediaDecoder.supportedTypes()).thenReturn(Arrays.asList(types));
+
+        builder.addMediaDecoder(mediaDecoder);
+
+        for (String type : types) {
+            assertEquals(type, mediaDecoder, builder.mediaDecoders.get(type));
+        }
+    }
+
+    @Test
+    public void default_media_decoder() {
+
+        assertNull(builder.defaultMediaDecoder);
+
+        final MediaDecoder mediaDecoder = mock(MediaDecoder.class);
+        builder.defaultMediaDecoder(mediaDecoder);
+        builder.build();
+
+        assertEquals(mediaDecoder, builder.defaultMediaDecoder);
+    }
+
+    @Test
+    public void remove_scheme_handler() {
+
+        final String scheme = "mock";
+        final SchemeHandler schemeHandler = mock(SchemeHandler.class);
+        when(schemeHandler.supportedSchemes()).thenReturn(Collections.singleton(scheme));
+
+        assertNull(builder.schemeHandlers.get(scheme));
+        builder.addSchemeHandler(schemeHandler);
+        assertNotNull(builder.schemeHandlers.get(scheme));
+        builder.removeSchemeHandler(scheme);
+        assertNull(builder.schemeHandlers.get(scheme));
+    }
+
+    @Test
+    public void remove_media_decoder() {
+
+        final String media = "mock/type";
+        final MediaDecoder mediaDecoder = mock(MediaDecoder.class);
+        when(mediaDecoder.supportedTypes()).thenReturn(Collections.singleton(media));
+
+        assertNull(builder.mediaDecoders.get(media));
+        builder.addMediaDecoder(mediaDecoder);
+        assertNotNull(builder.mediaDecoders.get(media));
+        builder.removeMediaDecoder(media);
+        assertNull(builder.mediaDecoders.get(media));
+    }
+}
\ No newline at end of file
diff --git a/markwon-image/src/test/java/ru/noties/markwon/image/ImagesPluginTest.java b/markwon-image/src/test/java/ru/noties/markwon/image/ImagesPluginTest.java
new file mode 100644
index 00000000..e141d456
--- /dev/null
+++ b/markwon-image/src/test/java/ru/noties/markwon/image/ImagesPluginTest.java
@@ -0,0 +1,177 @@
+package ru.noties.markwon.image;
+
+import android.support.annotation.NonNull;
+import android.text.Spanned;
+import android.widget.TextView;
+
+import org.commonmark.node.Image;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import java.util.concurrent.ExecutorService;
+
+import ru.noties.markwon.MarkwonConfiguration;
+import ru.noties.markwon.MarkwonSpansFactory;
+import ru.noties.markwon.SpanFactory;
+import ru.noties.markwon.image.data.DataUriSchemeHandler;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = Config.NONE)
+public class ImagesPluginTest {
+
+    private ImagesPlugin plugin;
+
+    @Before
+    public void before() {
+        plugin = ImagesPlugin.create();
+    }
+
+    @Test
+    public void build_state() {
+        // it's not possible to mutate images-plugin after `configureConfiguration` call
+
+        // validate that it doesn't throw here
+        plugin.addSchemeHandler(DataUriSchemeHandler.create());
+
+        // mark the state
+        plugin.configureConfiguration(mock(MarkwonConfiguration.Builder.class));
+
+        final class Throws {
+            private void assertThrows(@NonNull Runnable action) {
+                //noinspection CatchMayIgnoreException
+                try {
+                    action.run();
+                    fail();
+                } catch (Throwable t) {
+                    assertTrue(t.getMessage(), t.getMessage().contains("ImagesPlugin has already been configured"));
+                }
+            }
+        }
+        final Throws check = new Throws();
+
+        // executor-service
+        check.assertThrows(new Runnable() {
+            @Override
+            public void run() {
+                plugin.executorService(mock(ExecutorService.class));
+            }
+        });
+
+        // add-scheme-handler
+        check.assertThrows(new Runnable() {
+            @Override
+            public void run() {
+                plugin.addSchemeHandler(mock(SchemeHandler.class));
+            }
+        });
+
+        // add-media-decoder
+        check.assertThrows(new Runnable() {
+            @Override
+            public void run() {
+                plugin.addMediaDecoder(mock(MediaDecoder.class));
+            }
+        });
+
+        // default-media-decoder
+        check.assertThrows(new Runnable() {
+            @Override
+            public void run() {
+                plugin.defaultMediaDecoder(mock(MediaDecoder.class));
+            }
+        });
+
+        // remove-scheme-handler
+        check.assertThrows(new Runnable() {
+            @Override
+            public void run() {
+                plugin.removeSchemeHandler("mock");
+            }
+        });
+
+        // remove-media-decoder
+        check.assertThrows(new Runnable() {
+            @Override
+            public void run() {
+                plugin.removeMediaDecoder("mock/type");
+            }
+        });
+
+        // placeholder-provider
+        check.assertThrows(new Runnable() {
+            @Override
+            public void run() {
+                plugin.placeholderProvider(mock(ImagesPlugin.PlaceholderProvider.class));
+            }
+        });
+
+        // error-handler
+        check.assertThrows(new Runnable() {
+            @Override
+            public void run() {
+                plugin.errorHandler(mock(ImagesPlugin.ErrorHandler.class));
+            }
+        });
+
+        // final check if for actual `configureConfiguration` call (must be called only once)
+        check.assertThrows(new Runnable() {
+            @Override
+            public void run() {
+                plugin.configureConfiguration(mock(MarkwonConfiguration.Builder.class));
+            }
+        });
+    }
+
+    @Test
+    public void image_span_factory_registered() {
+
+        final MarkwonSpansFactory.Builder builder = mock(MarkwonSpansFactory.Builder.class);
+
+        plugin.configureSpansFactory(builder);
+
+        final ArgumentCaptor<SpanFactory> captor = ArgumentCaptor.forClass(SpanFactory.class);
+
+        verify(builder, times(1))
+                .setFactory(eq(Image.class), captor.capture());
+
+        assertNotNull(captor.getValue());
+    }
+
+    @Test
+    public void before_set_text() {
+        // verify that AsyncDrawableScheduler is called
+
+        final TextView textView = mock(TextView.class);
+
+        plugin.beforeSetText(textView, mock(Spanned.class));
+
+        verify(textView, times(1))
+                .getTag(eq(R.id.markwon_drawables_scheduler_last_text_hashcode));
+    }
+
+    @Test
+    public void after_set_text() {
+        // verify that AsyncDrawableScheduler is called
+
+        final TextView textView = mock(TextView.class);
+        when(textView.getText()).thenReturn("some text");
+
+        plugin.afterSetText(textView);
+
+        verify(textView, times(1))
+                .getTag(eq(R.id.markwon_drawables_scheduler_last_text_hashcode));
+    }
+}
\ No newline at end of file
diff --git a/markwon-linkify/README.md b/markwon-linkify/README.md
new file mode 100644
index 00000000..62d2cc75
--- /dev/null
+++ b/markwon-linkify/README.md
@@ -0,0 +1,5 @@
+# Linkify
+
+Use this module (or take a hint from it) if you would need _linkify_ capabilities. Do not
+use `TextView.setAutolinkMask` (or specify `autolink` in XML) because it will remove all 
+existing links and keep only the ones it creates.
\ No newline at end of file
diff --git a/markwon-linkify/src/main/java/ru/noties/markwon/linkify/LinkifyPlugin.java b/markwon-linkify/src/main/java/ru/noties/markwon/linkify/LinkifyPlugin.java
index ff29c1a1..b24f3b49 100644
--- a/markwon-linkify/src/main/java/ru/noties/markwon/linkify/LinkifyPlugin.java
+++ b/markwon-linkify/src/main/java/ru/noties/markwon/linkify/LinkifyPlugin.java
@@ -18,8 +18,7 @@ public class LinkifyPlugin extends AbstractMarkwonPlugin {
     @IntDef(flag = true, value = {
             Linkify.EMAIL_ADDRESSES,
             Linkify.PHONE_NUMBERS,
-            Linkify.WEB_URLS,
-            Linkify.ALL
+            Linkify.WEB_URLS
     })
     @Retention(RetentionPolicy.SOURCE)
     @interface LinkifyMask {