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 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() { + @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 map; + + TestConfig(@NonNull Map 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 output; + + TestData( + @Nullable String description, + @NonNull String input, + @NonNull TestConfig config, + @NonNull List 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 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 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() { + @Override + public boolean test(String s) { + return s.endsWith(".yaml"); + } + }) + .map(new IxFunction() { + @Override + public String apply(String s) { + return FOLDER + s; + } + }) + .map(new IxFunction() { + @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 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 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 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 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 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 attributes(@NonNull JsonElement element) { + + final JsonObject object = element.isJsonObject() + ? element.getAsJsonObject() + : null; + + final Map 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 attributes; + + TestEntry(@NonNull String name, @NonNull String text, @NonNull Map 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 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 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 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 map(Pair... pairs) { + final int length = pairs.length; + final Map 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 attributes; + + TestSpan(@NonNull String name) { + this(name, Collections.emptyMap()); + } + + TestSpan(@NonNull String name, @NonNull Map attributes) { + this.name = name; + this.attributes = attributes; + } + + @NonNull + public String name() { + return name; + } + + @NonNull + public Map 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" +