diff options
author | sebastian.sellmair <sebastian.sellmair@jetbrains.com> | 2020-08-24 14:02:07 +0200 |
---|---|---|
committer | Paweł Marks <Kordyjan@users.noreply.github.com> | 2020-08-25 16:21:32 +0200 |
commit | 7196323582dce7ca3f9b07262a1f94ecd8514539 (patch) | |
tree | 0c326aee84d4727d5cd5bc5c8198b992d5b58de6 /core/content-matcher-test-utils/src/main/kotlin/matchers | |
parent | 219e2c98f5d03fc8581fd6ce9dd870919523be44 (diff) | |
download | dokka-7196323582dce7ca3f9b07262a1f94ecd8514539.tar.gz dokka-7196323582dce7ca3f9b07262a1f94ecd8514539.tar.bz2 dokka-7196323582dce7ca3f9b07262a1f94ecd8514539.zip |
- Move `test` projects into semantic parent projects
- Implement new `:test-utils` project
- Resolve requirement for Android SDK installation
Diffstat (limited to 'core/content-matcher-test-utils/src/main/kotlin/matchers')
-rw-r--r-- | core/content-matcher-test-utils/src/main/kotlin/matchers/content/ContentMatchersDsl.kt | 120 | ||||
-rw-r--r-- | core/content-matcher-test-utils/src/main/kotlin/matchers/content/contentMatchers.kt | 183 |
2 files changed, 303 insertions, 0 deletions
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 |