diff --git a/markwon-test-span/build.gradle b/markwon-test-span/build.gradle
new file mode 100644
index 00000000..cba39010
--- /dev/null
+++ b/markwon-test-span/build.gradle
@@ -0,0 +1,24 @@
+apply plugin: 'com.android.library'
+
+android {
+
+ compileSdkVersion config['compile-sdk']
+ buildToolsVersion config['build-tools']
+
+ defaultConfig {
+ minSdkVersion config['min-sdk']
+ targetSdkVersion config['target-sdk']
+ versionCode 1
+ versionName version
+ }
+}
+
+dependencies {
+
+ api deps['support-annotations']
+
+ deps['test'].with {
+ api it['junit']
+ api it['ix-java']
+ }
+}
diff --git a/markwon-test-span/src/main/AndroidManifest.xml b/markwon-test-span/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..c2e8a267
--- /dev/null
+++ b/markwon-test-span/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/markwon-test-span/src/main/java/ru/noties/markwon/test/TestSpan.java b/markwon-test-span/src/main/java/ru/noties/markwon/test/TestSpan.java
new file mode 100644
index 00000000..bc2bd524
--- /dev/null
+++ b/markwon-test-span/src/main/java/ru/noties/markwon/test/TestSpan.java
@@ -0,0 +1,124 @@
+package ru.noties.markwon.test;
+
+import android.support.annotation.NonNull;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Utility class to validate spannable content
+ *
+ * @since 3.0.0
+ */
+public abstract class TestSpan {
+
+ @NonNull
+ public static TestSpan.Document document(TestSpan... children) {
+ return new TestSpanDocument(children(children));
+ }
+
+ @NonNull
+ public static TestSpan.Span span(@NonNull String name, TestSpan... children) {
+ return span(name, Collections.emptyMap(), children);
+ }
+
+ @NonNull
+ public static TestSpan.Span span(@NonNull String name, @NonNull Map arguments, TestSpan... children) {
+ return new TestSpanSpan(name, children(children), arguments);
+ }
+
+ @NonNull
+ public static TestSpan.Text text(@NonNull String literal) {
+ return new TestSpanText(literal);
+ }
+
+ @NonNull
+ public static List children(TestSpan... children) {
+ final int length = children.length;
+ final List list;
+ if (length == 0) {
+ list = Collections.emptyList();
+ } else if (length == 1) {
+ list = Collections.singletonList(children[0]);
+ } else {
+ final List spans = new ArrayList<>(length);
+ Collections.addAll(spans, children);
+ list = Collections.unmodifiableList(spans);
+ }
+ return list;
+ }
+
+ @NonNull
+ public static Map args(Object... args) {
+
+ final int length = args.length;
+ if (length == 0) {
+ return Collections.emptyMap();
+ }
+
+ // validate that length is even (k=v)
+ if ((length % 2) != 0) {
+ throw new IllegalStateException("Supplied key-values array must contain " +
+ "even number of arguments");
+ }
+
+ final Map map = new HashMap<>(length / 2 + 1);
+
+ String key;
+ Object value;
+
+ for (int i = 0; i < length; i += 2) {
+ // possible class-cast exception
+ key = (String) args[i];
+ value = args[i + 1];
+ map.put(key, value);
+ }
+
+ return Collections.unmodifiableMap(map);
+ }
+
+
+ @NonNull
+ public abstract List children();
+
+ @Override
+ public abstract int hashCode();
+
+ @Override
+ public abstract boolean equals(Object o);
+
+
+ public static abstract class Document extends TestSpan {
+
+ @NonNull
+ public abstract String wholeText();
+ }
+
+ public static abstract class Text extends TestSpan {
+
+ @NonNull
+ public abstract String literal();
+
+ public abstract int length();
+ }
+
+ public static abstract class Span extends TestSpan {
+
+ @NonNull
+ public abstract String name();
+
+ @NonNull
+ public abstract Map arguments();
+
+ @NonNull
+ @Override
+ public abstract List children();
+ }
+
+ // package-private constructor
+ TestSpan() {
+ }
+}
diff --git a/markwon-test-span/src/main/java/ru/noties/markwon/test/TestSpanDocument.java b/markwon-test-span/src/main/java/ru/noties/markwon/test/TestSpanDocument.java
new file mode 100644
index 00000000..ef876311
--- /dev/null
+++ b/markwon-test-span/src/main/java/ru/noties/markwon/test/TestSpanDocument.java
@@ -0,0 +1,60 @@
+package ru.noties.markwon.test;
+
+import android.support.annotation.NonNull;
+
+import java.util.List;
+
+class TestSpanDocument extends TestSpan.Document {
+
+ private static void fillWholeText(@NonNull StringBuilder builder, @NonNull TestSpan span) {
+ if (span instanceof Text) {
+ builder.append(((Text) span).literal());
+ } else if (span instanceof Span) {
+ for (TestSpan child : span.children()) {
+ fillWholeText(builder, child);
+ }
+ } else {
+ throw new IllegalStateException("Unexpected state. Found unexpected TestSpan " +
+ "object of type `" + span.getClass().getName() + "`");
+ }
+ }
+
+ private final List children;
+
+ TestSpanDocument(@NonNull List children) {
+ this.children = children;
+ }
+
+ @NonNull
+ @Override
+ public List children() {
+ return children;
+ }
+
+ @NonNull
+ @Override
+ public String wholeText() {
+ final StringBuilder builder = new StringBuilder();
+
+ for (TestSpan child : children) {
+ fillWholeText(builder, child);
+ }
+
+ return builder.toString();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ TestSpanDocument that = (TestSpanDocument) o;
+
+ return children.equals(that.children);
+ }
+
+ @Override
+ public int hashCode() {
+ return children.hashCode();
+ }
+}
diff --git a/markwon-test-span/src/main/java/ru/noties/markwon/test/TestSpanEnumerator.java b/markwon-test-span/src/main/java/ru/noties/markwon/test/TestSpanEnumerator.java
new file mode 100644
index 00000000..1c2172db
--- /dev/null
+++ b/markwon-test-span/src/main/java/ru/noties/markwon/test/TestSpanEnumerator.java
@@ -0,0 +1,34 @@
+package ru.noties.markwon.test;
+
+import android.support.annotation.NonNull;
+
+public class TestSpanEnumerator {
+
+ public interface Listener {
+ void onNext(int start, int end, @NonNull TestSpan span);
+ }
+
+ public void enumerate(@NonNull TestSpan.Document document, @NonNull Listener listener) {
+ visit(0, document, listener);
+ }
+
+ private int visit(int start, @NonNull TestSpan span, @NonNull Listener listener) {
+
+ if (span instanceof TestSpan.Text) {
+ final int end = start + ((TestSpan.Text) span).length();
+ listener.onNext(start, end, span);
+ return end;
+ }
+
+ // yeah, we will need end... and from recursive call also -> children can have text inside
+ int s = start;
+
+ for (TestSpan child : span.children()) {
+ s = visit(s, child, listener);
+ }
+
+ listener.onNext(start, s, span);
+
+ return s;
+ }
+}
diff --git a/markwon-test-span/src/main/java/ru/noties/markwon/test/TestSpanMatcher.java b/markwon-test-span/src/main/java/ru/noties/markwon/test/TestSpanMatcher.java
new file mode 100644
index 00000000..e9bf190b
--- /dev/null
+++ b/markwon-test-span/src/main/java/ru/noties/markwon/test/TestSpanMatcher.java
@@ -0,0 +1,131 @@
+package ru.noties.markwon.test;
+
+import android.support.annotation.NonNull;
+import android.text.Spanned;
+
+import junit.framework.Assert;
+import junit.framework.ComparisonFailure;
+
+import java.util.Locale;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import ix.Ix;
+import ix.IxPredicate;
+
+public abstract class TestSpanMatcher {
+
+ public static void matches(@NonNull final Spanned spanned, @NonNull TestSpan.Document document) {
+
+ // assert number for spans
+ // assert raw text
+
+ final TestSpanEnumerator enumerator = new TestSpanEnumerator();
+
+ // keep track of total spans encountered
+ final AtomicInteger counter = new AtomicInteger();
+
+ enumerator.enumerate(document, new TestSpanEnumerator.Listener() {
+ @Override
+ public void onNext(final int start, final int end, @NonNull TestSpan span) {
+ if (span instanceof TestSpan.Document) {
+
+ TestSpanMatcher.documentMatches(spanned, (TestSpan.Document) span);
+ } else if (span instanceof TestSpan.Span) {
+
+ // increment span count so after enumeration we match total number of spans
+ counter.incrementAndGet();
+
+ TestSpanMatcher.spanMatches(spanned, start, end, (TestSpan.Span) span);
+
+ } else if (span instanceof TestSpan.Text) {
+ TestSpanMatcher.textMatches(spanned, start, end, (TestSpan.Text) span);
+ } else {
+ // in case we add a new type
+ throw new IllegalStateException("Unexpected type of a TestSpan: `"
+ + span.getClass().getName() + "`, " + span);
+ }
+ }
+ });
+
+ final Object[] spans = spanned.getSpans(0, spanned.length(), Object.class);
+ Assert.assertEquals("Total spans count", counter.get(), spans.length);
+ }
+
+ public static void documentMatches(
+ @NonNull Spanned spanned,
+ @NonNull TestSpan.Document document) {
+
+ // match full text
+
+ final String expected = document.wholeText();
+ final String actual = spanned.toString();
+
+ if (!expected.equals(actual)) {
+ throw new ComparisonFailure(
+ "Document text mismatch",
+ expected,
+ actual);
+ }
+ }
+
+ public static void spanMatches(
+ @NonNull final Spanned spanned,
+ final int start,
+ final int end,
+ @NonNull TestSpan.Span expected) {
+
+ // when queried multiple spans can be returned (for example if one span
+ // wraps another one. so [0 1 [2 3] 4 5] where [] represents start/end of
+ // a span of same type, when queried for spans at 2-3 position, both will be returned
+ final TestSpan.Span actual = Ix.fromArray(spanned.getSpans(start, end, expected.getClass()))
+ .filter(new IxPredicate() {
+ @Override
+ public boolean test(TestSpan.Span span) {
+ return start == spanned.getSpanStart(span)
+ && end == spanned.getSpanEnd(span);
+ }
+ })
+ .first(null);
+
+ if (!expected.equals(actual)) {
+
+ final String expectedSpan = expected.arguments().isEmpty()
+ ? expected.name()
+ : expected.name() + ": " + expected.arguments();
+
+ final String actualSpan;
+ if (actual == null) {
+ actualSpan = "null";
+ } else {
+ actualSpan = actual.arguments().isEmpty()
+ ? actual.name()
+ : actual.name() + ": " + actual.arguments();
+ }
+
+ throw new AssertionError(
+ String.format(Locale.US, "Expected span{%s} at {start: %d, end: %d}, found: %s",
+ expectedSpan, start, end, actualSpan));
+ }
+ }
+
+ public static void textMatches(
+ @NonNull Spanned spanned,
+ int start,
+ int end,
+ @NonNull TestSpan.Text text) {
+
+ final String expected = text.literal();
+ final String actual = spanned.subSequence(start, end).toString();
+
+ if (!expected.equals(actual)) {
+ throw new ComparisonFailure(
+ String.format(Locale.US, "Text mismatch at {start: %d, end: %d}", start, end),
+ expected,
+ actual
+ );
+ }
+ }
+
+ private TestSpanMatcher() {
+ }
+}
diff --git a/markwon-test-span/src/main/java/ru/noties/markwon/test/TestSpanSpan.java b/markwon-test-span/src/main/java/ru/noties/markwon/test/TestSpanSpan.java
new file mode 100644
index 00000000..5dc60acd
--- /dev/null
+++ b/markwon-test-span/src/main/java/ru/noties/markwon/test/TestSpanSpan.java
@@ -0,0 +1,60 @@
+package ru.noties.markwon.test;
+
+import android.support.annotation.NonNull;
+
+import java.util.List;
+import java.util.Map;
+
+class TestSpanSpan extends TestSpan.Span {
+
+ private final String name;
+ private final List children;
+ private final Map arguments;
+
+ public TestSpanSpan(
+ @NonNull String name,
+ @NonNull List children,
+ @NonNull Map arguments) {
+ this.name = name;
+ this.children = children;
+ this.arguments = arguments;
+ }
+
+ @NonNull
+ @Override
+ public String name() {
+ return name;
+ }
+
+ @NonNull
+ @Override
+ public Map arguments() {
+ return arguments;
+ }
+
+ @NonNull
+ @Override
+ public List children() {
+ return children;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ TestSpanSpan that = (TestSpanSpan) o;
+
+ if (!name.equals(that.name)) return false;
+ if (!children.equals(that.children)) return false;
+ return arguments.equals(that.arguments);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = name.hashCode();
+ result = 31 * result + children.hashCode();
+ result = 31 * result + arguments.hashCode();
+ return result;
+ }
+}
diff --git a/markwon-test-span/src/main/java/ru/noties/markwon/test/TestSpanText.java b/markwon-test-span/src/main/java/ru/noties/markwon/test/TestSpanText.java
new file mode 100644
index 00000000..8a34a410
--- /dev/null
+++ b/markwon-test-span/src/main/java/ru/noties/markwon/test/TestSpanText.java
@@ -0,0 +1,47 @@
+package ru.noties.markwon.test;
+
+import android.support.annotation.NonNull;
+
+import java.util.Collections;
+import java.util.List;
+
+class TestSpanText extends TestSpan.Text {
+
+ private final String literal;
+
+ TestSpanText(@NonNull String literal) {
+ this.literal = literal;
+ }
+
+ @NonNull
+ @Override
+ public String literal() {
+ return literal;
+ }
+
+ @Override
+ public int length() {
+ return literal.length();
+ }
+
+ @NonNull
+ @Override
+ public List children() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ TestSpanText that = (TestSpanText) o;
+
+ return literal.equals(that.literal);
+ }
+
+ @Override
+ public int hashCode() {
+ return literal.hashCode();
+ }
+}
diff --git a/settings.gradle b/settings.gradle
index 10d8564b..d5f56c45 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -10,5 +10,6 @@ include ':app',
':markwon-syntax-highlight',
':markwon-html',
':markwon-view',
+ ':markwon-test-span',
':sample-custom-extension',
':sample-latex-math'