From 808349d56556b5b99fe7dcd95f723575c98dead8 Mon Sep 17 00:00:00 2001
From: Dimitry Ivanov <mail@dimitryivanov.ru>
Date: Thu, 23 Aug 2018 18:39:10 +0300
Subject: [PATCH] Working with test format

---
 build.gradle                                  |  11 +-
 markwon/build.gradle                          |   5 +
 .../visitor/SpannableMarkdownVisitorTest.java | 132 +++++++++
 .../markwon/renderer/visitor/TestConfig.java  |  31 ++
 .../markwon/renderer/visitor/TestData.java    |  45 +++
 .../renderer/visitor/TestDataReader.java      | 272 ++++++++++++++++++
 .../markwon/renderer/visitor/TestEntry.java   |  42 +++
 .../markwon/renderer/visitor/TestFactory.java | 189 ++++++++++++
 .../markwon/renderer/visitor/TestSpan.java    |  58 ++++
 markwon/src/test/resources/tests/first.yaml   |  23 ++
 10 files changed, 805 insertions(+), 3 deletions(-)
 create mode 100644 markwon/src/test/java/ru/noties/markwon/renderer/visitor/SpannableMarkdownVisitorTest.java
 create mode 100644 markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestConfig.java
 create mode 100644 markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestData.java
 create mode 100644 markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestDataReader.java
 create mode 100644 markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestEntry.java
 create mode 100644 markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestFactory.java
 create mode 100644 markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestSpan.java
 create mode 100644 markwon/src/test/resources/tests/first.yaml

diff --git a/build.gradle b/build.gradle
index 5858bb31..c40523ba 100644
--- a/build.gradle
+++ b/build.gradle
@@ -78,9 +78,14 @@ ext {
     ]
 
     deps['test'] = [
-            'junit'      : 'junit:junit:4.12',
-            'robolectric': 'org.robolectric:robolectric:3.8',
-            'ix-java'    : 'com.github.akarnokd:ixjava:1.0.0'
+            'junit'           : 'junit:junit:4.12',
+            'robolectric'     : 'org.robolectric:robolectric:3.8',
+            'ix-java'         : 'com.github.akarnokd:ixjava:1.0.0',
+            'jackson-yaml'    : 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.9.0',
+            'jackson-databind': 'com.fasterxml.jackson.core:jackson-databind:2.9.6',
+            'gson'            : 'com.google.code.gson:gson:2.8.5',
+            'commons-io'      : 'commons-io:commons-io:2.6',
+            'mockito'         : 'org.mockito:mockito-core:2.21.0'
     ]
 }
 
diff --git a/markwon/build.gradle b/markwon/build.gradle
index 1db4a53d..d753a53e 100644
--- a/markwon/build.gradle
+++ b/markwon/build.gradle
@@ -29,6 +29,11 @@ dependencies {
         testImplementation it['junit']
         testImplementation it['robolectric']
         testImplementation it['ix-java']
+        testImplementation it['jackson-yaml']
+        testImplementation it['jackson-databind']
+        testImplementation it['gson']
+        testImplementation it['commons-io']
+        testImplementation it['mockito']
     }
 }
 
diff --git a/markwon/src/test/java/ru/noties/markwon/renderer/visitor/SpannableMarkdownVisitorTest.java b/markwon/src/test/java/ru/noties/markwon/renderer/visitor/SpannableMarkdownVisitorTest.java
new file mode 100644
index 00000000..fa1282cd
--- /dev/null
+++ b/markwon/src/test/java/ru/noties/markwon/renderer/visitor/SpannableMarkdownVisitorTest.java
@@ -0,0 +1,132 @@
+package ru.noties.markwon.renderer.visitor;
+
+import android.support.annotation.NonNull;
+import android.text.SpannableStringBuilder;
+
+import org.commonmark.node.Node;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import java.util.Arrays;
+import java.util.Collection;
+
+import ix.Ix;
+import ix.IxPredicate;
+import ru.noties.markwon.LinkResolverDef;
+import ru.noties.markwon.Markwon;
+import ru.noties.markwon.SpannableBuilder;
+import ru.noties.markwon.SpannableConfiguration;
+import ru.noties.markwon.SpannableFactory;
+import ru.noties.markwon.renderer.SpannableMarkdownVisitor;
+import ru.noties.markwon.spans.SpannableTheme;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+
+@RunWith(ParameterizedRobolectricTestRunner.class)
+@Config(manifest = Config.NONE)
+public class SpannableMarkdownVisitorTest {
+
+    @ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
+    public static Collection<Object> parameters() {
+        return TestDataReader.testFiles();
+    }
+
+    private final String file;
+
+    public SpannableMarkdownVisitorTest(@NonNull String file) {
+        this.file = file;
+    }
+
+    @Test
+    public void test() {
+
+        final TestData data = TestDataReader.readTest(file);
+
+        final SpannableConfiguration configuration = configuration(data.config());
+        final SpannableBuilder builder = new SpannableBuilder();
+        final SpannableMarkdownVisitor visitor = new SpannableMarkdownVisitor(configuration, builder);
+        final Node node = Markwon.createParser().parse(data.input());
+        node.accept(visitor);
+
+        final SpannableStringBuilder stringBuilder = builder.spannableStringBuilder();
+        final String raw = stringBuilder.toString();
+
+        int index = 0;
+        int lastIndex = 0;
+
+        for (TestEntry entry : data.output()) {
+
+            final String expected = entry.text();
+
+            final boolean isText = "text".equals(entry.name());
+
+            final int start;
+            final int end;
+
+            if (isText) {
+                start = lastIndex;
+                end = start + expected.length();
+                index = lastIndex = end;
+            } else {
+                start = raw.indexOf(expected, index);
+                if (start < 0) {
+                    throw new AssertionError(String.format("Cannot find `%s` starting at index: %d, raw: %n###%n%s%n###",
+                            expected, start, raw
+                    ));
+                }
+                end = start + expected.length();
+                lastIndex = Math.max(end, lastIndex);
+            }
+
+            if (!expected.equals(raw.substring(start, end))) {
+                throw new AssertionError(String.format("Expected: `%s`, actual: `%s`, start: %d, raw: %n###%n%s%n###",
+                        expected, raw.substring(start, end), start, raw
+                ));
+            }
+
+            final Object[] spans = stringBuilder.getSpans(start, end, Object.class);
+            final int length = spans != null ? spans.length : 0;
+
+            if (isText) {
+                // validate no spans
+                assertEquals(Arrays.toString(spans), 0, length);
+            } else {
+                assertTrue(length > 0);
+                final Object span = Ix.fromArray(spans)
+                        .filter(new IxPredicate<Object>() {
+                            @Override
+                            public boolean test(Object o) {
+                                return start == stringBuilder.getSpanStart(o)
+                                        && end == stringBuilder.getSpanEnd(o);
+                            }
+                        })
+                        .first(null);
+                assertNotNull(span);
+                assertTrue(span instanceof TestSpan);
+                final TestSpan testSpan = (TestSpan) span;
+                assertEquals(entry.name(), testSpan.name());
+                assertEquals(entry.attributes(), testSpan.attributes());
+            }
+        }
+    }
+
+    @NonNull
+    private SpannableConfiguration configuration(@NonNull TestConfig config) {
+
+        final SpannableFactory factory = new TestFactory(config.hasOption(TestConfig.USE_PARAGRAPHS));
+
+        // todo: rest omitted for now
+        return SpannableConfiguration.builder(null)
+                .theme(mock(SpannableTheme.class))
+                .linkResolver(mock(LinkResolverDef.class))
+                .factory(factory)
+                .build();
+
+//        return configuration;
+    }
+}
\ No newline at end of file
diff --git a/markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestConfig.java b/markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestConfig.java
new file mode 100644
index 00000000..61fc29a5
--- /dev/null
+++ b/markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestConfig.java
@@ -0,0 +1,31 @@
+package ru.noties.markwon.renderer.visitor;
+
+import android.support.annotation.NonNull;
+
+import java.util.Map;
+
+class TestConfig {
+
+    static final String USE_PARAGRAPHS = "use-paragraphs";
+    static final String USE_HTML = "use-html";
+    static final String SOFT_BREAK_ADDS_NEW_LINE = "soft-break-adds-new-line";
+    static final String HTML_ALLOW_NON_CLOSED_TAGS = "html-allow-non-closed-tags";
+
+    private final Map<String, Boolean> map;
+
+    TestConfig(@NonNull Map<String, Boolean> map) {
+        this.map = map;
+    }
+
+    boolean hasOption(@NonNull String option) {
+        final Boolean value = map.get(option);
+        return value != null && value;
+    }
+
+    @Override
+    public String toString() {
+        return "TestConfig{" +
+                "map=" + map +
+                '}';
+    }
+}
diff --git a/markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestData.java b/markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestData.java
new file mode 100644
index 00000000..1e643b26
--- /dev/null
+++ b/markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestData.java
@@ -0,0 +1,45 @@
+package ru.noties.markwon.renderer.visitor;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+import java.util.List;
+
+class TestData {
+
+    private final String description;
+    private final String input;
+    private final TestConfig config;
+    private final List<TestEntry> output;
+
+    TestData(
+            @Nullable String description,
+            @NonNull String input,
+            @NonNull TestConfig config,
+            @NonNull List<TestEntry> output) {
+        this.description = description;
+        this.input = input;
+        this.config = config;
+        this.output = output;
+    }
+
+    @Nullable
+    public String description() {
+        return description;
+    }
+
+    @NonNull
+    public String input() {
+        return input;
+    }
+
+    @NonNull
+    public TestConfig config() {
+        return config;
+    }
+
+    @NonNull
+    public List<TestEntry> output() {
+        return output;
+    }
+}
diff --git a/markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestDataReader.java b/markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestDataReader.java
new file mode 100644
index 00000000..f9f1dbef
--- /dev/null
+++ b/markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestDataReader.java
@@ -0,0 +1,272 @@
+package ru.noties.markwon.renderer.visitor;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
+
+import org.apache.commons.io.IOUtils;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import ix.Ix;
+import ix.IxFunction;
+import ix.IxPredicate;
+
+abstract class TestDataReader {
+
+    private static final String FOLDER = "tests/";
+
+    @NonNull
+    static Collection<Object> testFiles() {
+
+        final InputStream in = TestDataReader.class.getClassLoader().getResourceAsStream(FOLDER);
+        if (in == null) {
+            throw new RuntimeException("Cannot access test cases folder");
+        }
+
+        try {
+            //noinspection unchecked
+            return (Collection) Ix.from(IOUtils.readLines(in, StandardCharsets.UTF_8))
+                    .filter(new IxPredicate<String>() {
+                        @Override
+                        public boolean test(String s) {
+                            return s.endsWith(".yaml");
+                        }
+                    })
+                    .map(new IxFunction<String, String>() {
+                        @Override
+                        public String apply(String s) {
+                            return FOLDER + s;
+                        }
+                    })
+                    .map(new IxFunction<String, Object[]>() {
+                        @Override
+                        public Object[] apply(String s) {
+                            return new Object[]{
+                                    s
+                            };
+                        }
+                    })
+                    .toList();
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    @NonNull
+    static TestData readTest(@NonNull String file) {
+        return new Reader(file).read();
+    }
+
+    private TestDataReader() {
+    }
+
+    static class Reader {
+
+        private final String file;
+
+        Reader(@NonNull String file) {
+            this.file = file;
+        }
+
+        @NonNull
+        TestData read() {
+            return testData(jsonObject());
+        }
+
+        @NonNull
+        private JsonObject jsonObject() {
+            try {
+                final String input = IOUtils.resourceToString(file, StandardCharsets.UTF_8, TestDataReader.class.getClassLoader());
+                final ObjectMapper objectMapper = new ObjectMapper(new YAMLFactory());
+                final Object object = objectMapper.readValue(input, Object.class);
+                final ObjectMapper jsonWriter = new ObjectMapper();
+                final String json = jsonWriter.writeValueAsString(object);
+                return new Gson().fromJson(json, JsonObject.class);
+            } catch (Throwable t) {
+                throw new RuntimeException(t);
+            }
+        }
+
+        @NonNull
+        private TestData testData(@NonNull JsonObject jsonObject) {
+
+            final String description = jsonObject.get("description").getAsString();
+
+            final String input = jsonObject.get("input").getAsString();
+            if (TextUtils.isEmpty(input)) {
+                throw new RuntimeException(String.format("Test case file `%s` is missing input parameter", file));
+            }
+
+            final List<TestEntry> testSpans = testEntries(jsonObject.get("output").getAsJsonArray());
+            if (testSpans.size() == 0) {
+                throw new RuntimeException(String.format("Test case file `%s` has no output specified", file));
+            }
+
+            return new TestData(
+                    description,
+                    input,
+                    testConfig(jsonObject.get("config")),
+                    testSpans
+            );
+        }
+
+        // todo: rename TestNode -> it's not a node... but... what?
+
+        @NonNull
+        private List<TestEntry> testEntries(@NonNull JsonArray array) {
+
+            // all items are contained in this array
+            // key is the name of an item
+            // item can be defined like this:
+            //  link: "text-content-of-the-link"
+            //  link:
+            //      attributes:
+            //      - href: "my-href-attribute"
+            //      text: "and here is the text content"
+            // text node can be found only at root level
+
+            final int length = array.size();
+
+            final List<TestEntry> testSpans = new ArrayList<>(length);
+
+            for (int i = 0; i < length; i++) {
+
+                final JsonElement element = array.get(i);
+
+                if (element.isJsonObject()) {
+
+                    final JsonObject object = element.getAsJsonObject();
+
+                    // objects must have exactly 1 key: name of the node
+                    // it's value can be different: JsonPrimitive (String) or an JsonObject (with attributes and text)
+
+                    final String name = object.keySet().iterator().next();
+                    final JsonElement value = object.get(name);
+
+                    final String text;
+                    final Map<String, String> attributes;
+
+                    if (value.isJsonObject()) {
+
+                        final JsonObject valueObject = value.getAsJsonObject();
+                        text = valueObject.get("text").getAsString();
+                        attributes = attributes(valueObject.get("attrs"));
+
+                    } else {
+
+                        final JsonPrimitive primitive;
+
+                        if (value.isJsonPrimitive()) {
+                            primitive = value.getAsJsonPrimitive();
+                        } else {
+                            primitive = null;
+                        }
+
+                        if (primitive == null
+                                || !primitive.isString()) {
+                            throw new RuntimeException(String.format("Unexpected json element at index: `%d` in array: `%s`",
+                                    i, array
+                            ));
+                        }
+
+                        text = primitive.getAsString();
+                        attributes = Collections.emptyMap();
+                    }
+
+                    testSpans.add(new TestEntry(name, text, attributes));
+
+                } else {
+                    throw new RuntimeException(String.format("Unexpected json element at index: `%d` in array: `%s`",
+                            i, array
+                    ));
+                }
+            }
+
+            return testSpans;
+        }
+
+        @NonNull
+        private TestConfig testConfig(@Nullable JsonElement element) {
+
+            final JsonObject object = element != null && element.isJsonObject()
+                    ? element.getAsJsonObject()
+                    : null;
+
+            final Map<String, Boolean> map;
+
+            if (object != null) {
+
+                map = new HashMap<>(object.size());
+
+                for (String key : object.keySet()) {
+
+                    final JsonElement value = object.get(key);
+
+                    if (value.isJsonPrimitive()) {
+
+                        final JsonPrimitive jsonPrimitive = value.getAsJsonPrimitive();
+
+                        Boolean b = null;
+
+                        if (jsonPrimitive.isBoolean()) {
+                            b = jsonPrimitive.getAsBoolean();
+                        } else if (jsonPrimitive.isString()) {
+                            final String s = jsonPrimitive.getAsString();
+                            if ("true".equalsIgnoreCase(s)) {
+                                b = Boolean.TRUE;
+                            } else if ("false".equalsIgnoreCase(s)) {
+                                b = Boolean.FALSE;
+                            }
+                        }
+
+                        if (b != null) {
+                            map.put(key, b);
+                        }
+                    }
+                }
+            } else {
+                map = Collections.emptyMap();
+            }
+
+            return new TestConfig(map);
+        }
+
+        @NonNull
+        private static Map<String, String> attributes(@NonNull JsonElement element) {
+
+            final JsonObject object = element.isJsonObject()
+                    ? element.getAsJsonObject()
+                    : null;
+
+            final Map<String, String> attributes;
+
+            if (object != null) {
+                attributes = new HashMap<>(object.size());
+                for (String key : object.keySet()) {
+                    attributes.put(key, object.get(key).getAsString());
+                }
+            } else {
+                attributes = Collections.emptyMap();
+            }
+
+            return attributes;
+        }
+    }
+}
diff --git a/markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestEntry.java b/markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestEntry.java
new file mode 100644
index 00000000..be41dec4
--- /dev/null
+++ b/markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestEntry.java
@@ -0,0 +1,42 @@
+package ru.noties.markwon.renderer.visitor;
+
+import android.support.annotation.NonNull;
+
+import java.util.Map;
+
+public class TestEntry {
+
+    private final String name;
+    private final String text;
+    private final Map<String, String> attributes;
+
+    TestEntry(@NonNull String name, @NonNull String text, @NonNull Map<String, String> attributes) {
+        this.name = name;
+        this.text = text;
+        this.attributes = attributes;
+    }
+
+    @NonNull
+    public String name() {
+        return name;
+    }
+
+    @NonNull
+    public String text() {
+        return text;
+    }
+
+    @NonNull
+    public Map<String, String> attributes() {
+        return attributes;
+    }
+
+    @Override
+    public String toString() {
+        return "TestEntry{" +
+                "name='" + name + '\'' +
+                ", text='" + text + '\'' +
+                ", attributes=" + attributes +
+                '}';
+    }
+}
diff --git a/markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestFactory.java b/markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestFactory.java
new file mode 100644
index 00000000..717708eb
--- /dev/null
+++ b/markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestFactory.java
@@ -0,0 +1,189 @@
+package ru.noties.markwon.renderer.visitor;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import ru.noties.markwon.SpannableFactory;
+import ru.noties.markwon.renderer.ImageSize;
+import ru.noties.markwon.renderer.ImageSizeResolver;
+import ru.noties.markwon.spans.AsyncDrawable;
+import ru.noties.markwon.spans.LinkSpan;
+import ru.noties.markwon.spans.SpannableTheme;
+import ru.noties.markwon.spans.TableRowSpan;
+
+import static ru.noties.markwon.renderer.visitor.TestSpan.BLOCK_QUOTE;
+import static ru.noties.markwon.renderer.visitor.TestSpan.BULLET_LIST;
+import static ru.noties.markwon.renderer.visitor.TestSpan.CODE;
+import static ru.noties.markwon.renderer.visitor.TestSpan.EMPHASIS;
+import static ru.noties.markwon.renderer.visitor.TestSpan.HEADING;
+import static ru.noties.markwon.renderer.visitor.TestSpan.IMAGE;
+import static ru.noties.markwon.renderer.visitor.TestSpan.LINK;
+import static ru.noties.markwon.renderer.visitor.TestSpan.ORDERED_LIST;
+import static ru.noties.markwon.renderer.visitor.TestSpan.PARAGRAPH;
+import static ru.noties.markwon.renderer.visitor.TestSpan.STRIKE_THROUGH;
+import static ru.noties.markwon.renderer.visitor.TestSpan.STRONG_EMPHASIS;
+import static ru.noties.markwon.renderer.visitor.TestSpan.SUB_SCRIPT;
+import static ru.noties.markwon.renderer.visitor.TestSpan.SUPER_SCRIPT;
+import static ru.noties.markwon.renderer.visitor.TestSpan.TABLE_ROW;
+import static ru.noties.markwon.renderer.visitor.TestSpan.TASK_LIST;
+import static ru.noties.markwon.renderer.visitor.TestSpan.THEMATIC_BREAK;
+import static ru.noties.markwon.renderer.visitor.TestSpan.UNDERLINE;
+
+class TestFactory implements SpannableFactory {
+
+    private final boolean useParagraphs;
+
+    TestFactory(boolean useParagraphs) {
+        this.useParagraphs = useParagraphs;
+    }
+
+    @Nullable
+    @Override
+    public Object strongEmphasis() {
+        return new TestSpan(STRONG_EMPHASIS);
+    }
+
+    @Nullable
+    @Override
+    public Object emphasis() {
+        return new TestSpan(EMPHASIS);
+    }
+
+    @Nullable
+    @Override
+    public Object blockQuote(@NonNull SpannableTheme theme) {
+        return new TestSpan(BLOCK_QUOTE);
+    }
+
+    @Nullable
+    @Override
+    public Object code(@NonNull SpannableTheme theme, boolean multiline) {
+        return new TestSpan(CODE, map("multiline", multiline));
+    }
+
+    @Nullable
+    @Override
+    public Object orderedListItem(@NonNull SpannableTheme theme, int startNumber) {
+        return new TestSpan(ORDERED_LIST, map("startNumber", startNumber));
+    }
+
+    @Nullable
+    @Override
+    public Object bulletListItem(@NonNull SpannableTheme theme, int level) {
+        return new TestSpan(BULLET_LIST, map("level", level));
+    }
+
+    @Nullable
+    @Override
+    public Object thematicBreak(@NonNull SpannableTheme theme) {
+        return new TestSpan(THEMATIC_BREAK);
+    }
+
+    @Nullable
+    @Override
+    public Object heading(@NonNull SpannableTheme theme, int level) {
+        return new TestSpan(HEADING, map("level", level));
+    }
+
+    @Nullable
+    @Override
+    public Object strikethrough() {
+        return new TestSpan(STRIKE_THROUGH);
+    }
+
+    @Nullable
+    @Override
+    public Object taskListItem(@NonNull SpannableTheme theme, int blockIndent, boolean isDone) {
+        return new TestSpan(TASK_LIST, map(
+                Pair.of("blockIdent", blockIndent),
+                Pair.of("isDone", isDone)
+        ));
+    }
+
+    @Nullable
+    @Override
+    public Object tableRow(@NonNull SpannableTheme theme, @NonNull List<TableRowSpan.Cell> cells, boolean isHeader, boolean isOdd) {
+        return new TestSpan(TABLE_ROW, map(
+                Pair.of("cells", cells),
+                Pair.of("isHeader", isHeader),
+                Pair.of("isOdd", isOdd)
+        ));
+    }
+
+    @Nullable
+    @Override
+    public Object paragraph(boolean inTightList) {
+        return !useParagraphs
+                ? null
+                : new TestSpan(PARAGRAPH);
+    }
+
+    @Nullable
+    @Override
+    public Object image(@NonNull SpannableTheme theme, @NonNull String destination, @NonNull AsyncDrawable.Loader loader, @NonNull ImageSizeResolver imageSizeResolver, @Nullable ImageSize imageSize, boolean replacementTextIsLink) {
+        return new TestSpan(IMAGE, map(
+                Pair.of("destination", destination),
+                Pair.of("imageSize", imageSize),
+                Pair.of("replacementTextIsLink", replacementTextIsLink)
+        ));
+    }
+
+    @Nullable
+    @Override
+    public Object link(@NonNull SpannableTheme theme, @NonNull String destination, @NonNull LinkSpan.Resolver resolver) {
+        return new TestSpan(LINK, map("href", destination));
+    }
+
+    @Nullable
+    @Override
+    public Object superScript(@NonNull SpannableTheme theme) {
+        return new TestSpan(SUPER_SCRIPT);
+    }
+
+    @Nullable
+    @Override
+    public Object subScript(@NonNull SpannableTheme theme) {
+        return new TestSpan(SUB_SCRIPT);
+    }
+
+    @Nullable
+    @Override
+    public Object underline() {
+        return new TestSpan(UNDERLINE);
+    }
+
+    @NonNull
+    private static Map<String, String> map(@NonNull String key, @Nullable Object value) {
+        return Collections.singletonMap(key, String.valueOf(value));
+    }
+
+    private static class Pair {
+
+        static Pair of(@NonNull String key, @Nullable Object value) {
+            return new Pair(key, value);
+        }
+
+        final String key;
+        final Object value;
+
+        Pair(@NonNull String key, @Nullable Object value) {
+            this.key = key;
+            this.value = value;
+        }
+    }
+
+    @NonNull
+    private static Map<String, String> map(Pair... pairs) {
+        final int length = pairs.length;
+        final Map<String, String> map = new HashMap<>(length);
+        for (Pair pair : pairs) {
+            map.put(pair.key, String.valueOf(pair.value));
+        }
+        return map;
+    }
+}
diff --git a/markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestSpan.java b/markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestSpan.java
new file mode 100644
index 00000000..d07f61e9
--- /dev/null
+++ b/markwon/src/test/java/ru/noties/markwon/renderer/visitor/TestSpan.java
@@ -0,0 +1,58 @@
+package ru.noties.markwon.renderer.visitor;
+
+import android.support.annotation.NonNull;
+
+import java.util.Collections;
+import java.util.Map;
+
+class TestSpan {
+
+    static final String STRONG_EMPHASIS = "b";
+    static final String EMPHASIS = "i";
+    static final String BLOCK_QUOTE = "blockquote";
+    static final String CODE = "code";
+    static final String ORDERED_LIST = "ol";
+    static final String BULLET_LIST = "ul";
+    static final String THEMATIC_BREAK = "hr";
+    static final String HEADING = "h";
+    static final String STRIKE_THROUGH = "s";
+    static final String TASK_LIST = "task-list";
+    static final String TABLE_ROW = "tr";
+    static final String PARAGRAPH = "p";
+    static final String IMAGE = "img";
+    static final String LINK = "a";
+    static final String SUPER_SCRIPT = "sup";
+    static final String SUB_SCRIPT = "sub";
+    static final String UNDERLINE = "u";
+
+
+    private final String name;
+    private final Map<String, String> attributes;
+
+    TestSpan(@NonNull String name) {
+        this(name, Collections.<String, String>emptyMap());
+    }
+
+    TestSpan(@NonNull String name, @NonNull Map<String, String> attributes) {
+        this.name = name;
+        this.attributes = attributes;
+    }
+
+    @NonNull
+    public String name() {
+        return name;
+    }
+
+    @NonNull
+    public Map<String, String> attributes() {
+        return attributes;
+    }
+
+    @Override
+    public String toString() {
+        return "TestSpan{" +
+                "name='" + name + '\'' +
+                ", attributes=" + attributes +
+                '}';
+    }
+}
diff --git a/markwon/src/test/resources/tests/first.yaml b/markwon/src/test/resources/tests/first.yaml
new file mode 100644
index 00000000..b782b28d
--- /dev/null
+++ b/markwon/src/test/resources/tests/first.yaml
@@ -0,0 +1,23 @@
+description: Defining test case format
+
+input: |-
+  Here is some [link](https://my.href)
+  **bold _bold italic_ bold** normal
+
+config:
+  use-paragraphs: false
+  use-html: false
+  soft-break-adds-new-line: false
+  html-allow-non-closed-tags: false
+
+output:
+  - text: "Here is some "
+  - a:
+      attrs:
+        href: "https://my.href"
+      text: "link"
+  - text: " "
+  - b: "bold bold italic bold"
+  - i: "bold italic"
+  - text: " normal"
+