aboutsummaryrefslogtreecommitdiff
path: root/core/content-matcher-test-utils
diff options
context:
space:
mode:
Diffstat (limited to 'core/content-matcher-test-utils')
-rw-r--r--core/content-matcher-test-utils/build.gradle.kts6
-rw-r--r--core/content-matcher-test-utils/src/main/kotlin/matchers/content/ContentMatchersDsl.kt120
-rw-r--r--core/content-matcher-test-utils/src/main/kotlin/matchers/content/contentMatchers.kt183
3 files changed, 309 insertions, 0 deletions
diff --git a/core/content-matcher-test-utils/build.gradle.kts b/core/content-matcher-test-utils/build.gradle.kts
new file mode 100644
index 00000000..135fc514
--- /dev/null
+++ b/core/content-matcher-test-utils/build.gradle.kts
@@ -0,0 +1,6 @@
+dependencies {
+ implementation(project(":core:test-api"))
+ implementation(kotlin("stdlib-jdk8"))
+ implementation(kotlin("reflect"))
+ implementation("com.willowtreeapps.assertk:assertk-jvm:0.22")
+}
diff --git a/core/content-matcher-test-utils/src/main/kotlin/matchers/content/ContentMatchersDsl.kt b/core/content-matcher-test-utils/src/main/kotlin/matchers/content/ContentMatchersDsl.kt
new file mode 100644
index 00000000..67c0e692
--- /dev/null
+++ b/core/content-matcher-test-utils/src/main/kotlin/matchers/content/ContentMatchersDsl.kt
@@ -0,0 +1,120 @@
+package matchers.content
+
+import assertk.assertThat
+import assertk.assertions.contains
+import assertk.assertions.isEqualTo
+import assertk.assertions.matches
+import org.jetbrains.dokka.model.withDescendants
+import org.jetbrains.dokka.pages.*
+import org.jetbrains.dokka.test.tools.matchers.content.*
+import kotlin.reflect.KClass
+
+// entry point:
+fun ContentNode.assertNode(block: ContentMatcherBuilder<ContentComposite>.() -> Unit) {
+ val matcher = ContentMatcherBuilder(ContentComposite::class).apply(block).build()
+ try {
+ matcher.tryMatch(this)
+ } catch (e: MatcherError) {
+ throw AssertionError(e.message + "\n" + matcher.toDebugString(e.anchor, e.anchorAfter))
+ }
+}
+
+
+// DSL:
+@DslMarker
+annotation class ContentMatchersDsl
+
+@ContentMatchersDsl
+class ContentMatcherBuilder<T : ContentComposite> @PublishedApi internal constructor(private val kclass: KClass<T>) {
+ @PublishedApi
+ internal val children = mutableListOf<MatcherElement>()
+ internal val assertions = mutableListOf<T.() -> Unit>()
+
+ fun build() = CompositeMatcher(kclass, childrenOrSkip()) { assertions.forEach { it() } }
+
+ // part of DSL that cannot be defined as an extension
+ operator fun String.unaryPlus() {
+ children += TextMatcher(this)
+ }
+
+ private fun childrenOrSkip() = if (children.isEmpty() && assertions.isNotEmpty()) listOf(Anything) else children
+}
+
+fun <T : ContentComposite> ContentMatcherBuilder<T>.check(assertion: T.() -> Unit) {
+ assertions += assertion
+}
+
+private val ContentComposite.extractedText
+ get() = withDescendants().filterIsInstance<ContentText>().joinToString(separator = "") { it.text }
+
+fun <T : ContentComposite> ContentMatcherBuilder<T>.hasExactText(expected: String) {
+ assertions += {
+ assertThat(this::extractedText).isEqualTo(expected)
+ }
+}
+
+fun <T : ContentComposite> ContentMatcherBuilder<T>.textMatches(pattern: Regex) {
+ assertions += {
+ assertThat(this::extractedText).matches(pattern)
+ }
+}
+
+inline fun <reified S : ContentComposite> ContentMatcherBuilder<*>.composite(
+ block: ContentMatcherBuilder<S>.() -> Unit
+) {
+ children += ContentMatcherBuilder(S::class).apply(block).build()
+}
+
+inline fun <reified S : ContentNode> ContentMatcherBuilder<*>.node(noinline assertions: S.() -> Unit = {}) {
+ children += NodeMatcher(S::class, assertions)
+}
+
+fun ContentMatcherBuilder<*>.skipAllNotMatching() {
+ children += Anything
+}
+
+
+// Convenience functions:
+fun ContentMatcherBuilder<*>.group(block: ContentMatcherBuilder<ContentGroup>.() -> Unit) = composite(block)
+
+fun ContentMatcherBuilder<*>.header(expectedLevel: Int? = null, block: ContentMatcherBuilder<ContentHeader>.() -> Unit) =
+ composite<ContentHeader> {
+ block()
+ check { if (expectedLevel != null) assertThat(this::level).isEqualTo(expectedLevel) }
+ }
+
+fun ContentMatcherBuilder<*>.p(block: ContentMatcherBuilder<ContentGroup>.() -> Unit) =
+ composite<ContentGroup> {
+ block()
+ check { assertThat(this::style).contains(TextStyle.Paragraph) }
+ }
+
+fun ContentMatcherBuilder<*>.link(block: ContentMatcherBuilder<ContentLink>.() -> Unit) = composite(block)
+
+fun ContentMatcherBuilder<*>.table(block: ContentMatcherBuilder<ContentTable>.() -> Unit) = composite(block)
+
+fun ContentMatcherBuilder<*>.platformHinted(block: ContentMatcherBuilder<ContentGroup>.() -> Unit) =
+ composite<PlatformHintedContent> { group(block) }
+
+fun ContentMatcherBuilder<*>.br() = node<ContentBreakLine>()
+
+fun ContentMatcherBuilder<*>.somewhere(block: ContentMatcherBuilder<*>.() -> Unit) {
+ skipAllNotMatching()
+ block()
+ skipAllNotMatching()
+}
+
+fun ContentMatcherBuilder<*>.divergentGroup(block: ContentMatcherBuilder<ContentDivergentGroup>.() -> Unit) =
+ composite(block)
+
+fun ContentMatcherBuilder<ContentDivergentGroup>.divergentInstance(block: ContentMatcherBuilder<ContentDivergentInstance>.() -> Unit) =
+ composite(block)
+
+fun ContentMatcherBuilder<ContentDivergentInstance>.before(block: ContentMatcherBuilder<ContentComposite>.() -> Unit) =
+ composite(block)
+
+fun ContentMatcherBuilder<ContentDivergentInstance>.divergent(block: ContentMatcherBuilder<ContentComposite>.() -> Unit) =
+ composite(block)
+
+fun ContentMatcherBuilder<ContentDivergentInstance>.after(block: ContentMatcherBuilder<ContentComposite>.() -> Unit) =
+ composite(block) \ No newline at end of file
diff --git a/core/content-matcher-test-utils/src/main/kotlin/matchers/content/contentMatchers.kt b/core/content-matcher-test-utils/src/main/kotlin/matchers/content/contentMatchers.kt
new file mode 100644
index 00000000..f42e28f3
--- /dev/null
+++ b/core/content-matcher-test-utils/src/main/kotlin/matchers/content/contentMatchers.kt
@@ -0,0 +1,183 @@
+package org.jetbrains.dokka.test.tools.matchers.content
+
+import org.jetbrains.dokka.model.asPrintableTree
+import org.jetbrains.dokka.pages.ContentComposite
+import org.jetbrains.dokka.pages.ContentNode
+import org.jetbrains.dokka.pages.ContentText
+import kotlin.reflect.KClass
+import kotlin.reflect.full.cast
+import kotlin.reflect.full.safeCast
+
+sealed class MatcherElement
+
+class TextMatcher(val text: String) : MatcherElement()
+
+open class NodeMatcher<T : ContentNode>(
+ val kclass: KClass<T>,
+ val assertions: T.() -> Unit = {}
+) : MatcherElement() {
+ open fun tryMatch(node: ContentNode) {
+ kclass.safeCast(node)?.apply {
+ try {
+ assertions()
+ } catch (e: AssertionError) {
+ throw MatcherError(
+ "${e.message.orEmpty()}\nin node:\n${node.debugRepresentation()}",
+ this@NodeMatcher,
+ cause = e
+ )
+ }
+ } ?: throw MatcherError("Expected ${kclass.simpleName} but got:\n${node.debugRepresentation()}", this)
+ }
+}
+
+class CompositeMatcher<T : ContentComposite>(
+ kclass: KClass<T>,
+ private val children: List<MatcherElement>,
+ assertions: T.() -> Unit = {}
+) : NodeMatcher<T>(kclass, assertions) {
+ internal val normalizedChildren: List<MatcherElement> by lazy {
+ children.fold(listOf<MatcherElement>()) { acc, e ->
+ when {
+ acc.lastOrNull() is Anything && e is Anything -> acc
+ acc.lastOrNull() is TextMatcher && e is TextMatcher ->
+ acc.dropLast(1) + TextMatcher((acc.lastOrNull() as TextMatcher).text + e.text)
+ else -> acc + e
+ }
+ }
+ }
+
+ override fun tryMatch(node: ContentNode) {
+ super.tryMatch(node)
+ kclass.cast(node).children.asSequence()
+ .filter { it !is ContentText || it.text.isNotBlank() }
+ .fold(FurtherSiblings(normalizedChildren, this).pop()) { acc, n -> acc.next(n) }.finish()
+ }
+}
+
+object Anything : MatcherElement()
+
+private sealed class MatchWalkerState {
+ abstract fun next(node: ContentNode): MatchWalkerState
+ abstract fun finish()
+}
+
+private class TextMatcherState(
+ val text: String,
+ val rest: FurtherSiblings,
+ val anchor: TextMatcher
+) : MatchWalkerState() {
+ override fun next(node: ContentNode): MatchWalkerState {
+ node as? ContentText ?: throw MatcherError("Expected text: \"$text\" but got\n${node.debugRepresentation()}", anchor)
+ val trimmed = node.text.trim()
+ return when {
+ text == trimmed -> rest.pop()
+ text.startsWith(trimmed) -> TextMatcherState(text.removePrefix(node.text).trim(), rest, anchor)
+ else -> throw MatcherError("Expected text: \"$text\", but got: \"${node.text}\"", anchor)
+ }
+ }
+
+ override fun finish() = throw MatcherError("\"$text\" was not found" + rest.messageEnd, anchor)
+}
+
+private class EmptyMatcherState(val parent: CompositeMatcher<*>) : MatchWalkerState() {
+ override fun next(node: ContentNode): MatchWalkerState {
+ throw MatcherError("Unexpected node:\n${node.debugRepresentation()}", parent, anchorAfter = true)
+ }
+
+ override fun finish() = Unit
+}
+
+private class NodeMatcherState(
+ val matcher: NodeMatcher<*>,
+ val rest: FurtherSiblings
+) : MatchWalkerState() {
+ override fun next(node: ContentNode): MatchWalkerState {
+ matcher.tryMatch(node)
+ return rest.pop()
+ }
+
+ override fun finish() =
+ throw MatcherError("Content of type ${matcher.kclass} was not found" + rest.messageEnd, matcher)
+}
+
+private class SkippingMatcherState(
+ val innerState: MatchWalkerState
+) : MatchWalkerState() {
+ override fun next(node: ContentNode): MatchWalkerState = runCatching { innerState.next(node) }.getOrElse { this }
+
+ override fun finish() = innerState.finish()
+}
+
+private class FurtherSiblings(val list: List<MatcherElement>, val parent: CompositeMatcher<*>) {
+ fun pop(): MatchWalkerState = when (val head = list.firstOrNull()) {
+ is TextMatcher -> TextMatcherState(head.text.trim(), drop(), head)
+ is NodeMatcher<*> -> NodeMatcherState(head, drop())
+ is Anything -> SkippingMatcherState(drop().pop())
+ null -> EmptyMatcherState(parent)
+ }
+
+ fun drop() = FurtherSiblings(list.drop(1), parent)
+
+ val messageEnd: String
+ get() = list.filter { it !is Anything }
+ .count().takeIf { it > 0 }
+ ?.let { " and $it further matchers were not satisfied" } ?: ""
+}
+
+
+internal fun MatcherElement.toDebugString(anchor: MatcherElement?, anchorAfter: Boolean): String {
+ fun Appendable.append(element: MatcherElement, ownPrefix: String, childPrefix: String) {
+ if (anchor != null) {
+ if (element != anchor || anchorAfter) append(" ".repeat(4))
+ else append("--> ")
+ }
+
+ append(ownPrefix)
+ when (element) {
+ is Anything -> append("skipAllNotMatching\n")
+ is TextMatcher -> append("\"${element.text}\"\n")
+ is CompositeMatcher<*> -> {
+ append("${element.kclass.simpleName.toString()}\n")
+ if (element.normalizedChildren.isNotEmpty()) {
+ val newOwnPrefix = childPrefix + '\u251c' + '\u2500' + ' '
+ val lastOwnPrefix = childPrefix + '\u2514' + '\u2500' + ' '
+ val newChildPrefix = childPrefix + '\u2502' + ' ' + ' '
+ val lastChildPrefix = childPrefix + ' ' + ' ' + ' '
+ element.normalizedChildren.forEachIndexed { n, e ->
+ if (n != element.normalizedChildren.lastIndex) append(e, newOwnPrefix, newChildPrefix)
+ else append(e, lastOwnPrefix, lastChildPrefix)
+ }
+ }
+ if (element == anchor && anchorAfter) {
+ append("--> $childPrefix\n")
+ }
+ }
+ is NodeMatcher<*> -> append("${element.kclass.simpleName}\n")
+ }
+ }
+
+ return buildString { append(this@toDebugString, "", "") }
+}
+
+private fun ContentNode.debugRepresentation() = asPrintableTree { element ->
+ append(if (element is ContentText) """"${element.text}"""" else element::class.simpleName)
+ append(
+ " { " +
+ "kind=${element.dci.kind}, " +
+ "dri=${element.dci.dri}, " +
+ "style=${element.style}, " +
+ "sourceSets=${element.sourceSets} " +
+ "}"
+ )
+}
+
+data class MatcherError(
+ override val message: String,
+ val anchor: MatcherElement,
+ val anchorAfter: Boolean = false,
+ override val cause: Throwable? = null
+) : AssertionError(message, cause)
+
+// Creating this whole mechanism was most scala-like experience I had since I stopped using scala.
+// I don't know how I should feel about it. \ No newline at end of file