Browse Source

web: Update test utils api to prepare it for publishing (#1048)

Co-authored-by: Oleksandr Karpovich <oleksandr.karpovich@jetbrains.com>
pull/1098/head
Oleksandr Karpovich 3 years ago committed by GitHub
parent
commit
2689f7c4c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      web/core/src/jsMain/kotlin/org/jetbrains/compose/web/RenderComposable.kt
  2. 14
      web/core/src/jsTest/kotlin/ControlledRadioGroupsTests.kt
  3. 8
      web/core/src/jsTest/kotlin/DomSideEffectTests.kt
  4. 8
      web/core/src/jsTest/kotlin/InlineStyleTests.kt
  5. 161
      web/core/src/jsTest/kotlin/TestUtils.kt
  6. 14
      web/core/src/jsTest/kotlin/elements/AttributesTests.kt
  7. 11
      web/core/src/jsTest/kotlin/elements/InputsGenerateCorrectHtmlTests.kt

4
web/core/src/jsMain/kotlin/org/jetbrains/compose/web/RenderComposable.kt

@ -3,6 +3,7 @@ package org.jetbrains.compose.web
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Composition
import androidx.compose.runtime.ControlledComposition
import androidx.compose.runtime.MonotonicFrameClock
import androidx.compose.runtime.DefaultMonotonicFrameClock
import androidx.compose.runtime.Recomposer
import org.jetbrains.compose.web.dom.DOMScope
@ -23,11 +24,12 @@ import org.w3c.dom.get
*/
fun <TElement : Element> renderComposable(
root: TElement,
monotonicFrameClock: MonotonicFrameClock = DefaultMonotonicFrameClock,
content: @Composable DOMScope<TElement>.() -> Unit
): Composition {
GlobalSnapshotManager.ensureStarted()
val context = DefaultMonotonicFrameClock + JsMicrotasksDispatcher()
val context = monotonicFrameClock + JsMicrotasksDispatcher()
val recomposer = Recomposer(context)
val composition = ControlledComposition(
applier = DomApplier(DomNodeWrapper(root)),

14
web/core/src/jsTest/kotlin/ControlledRadioGroupsTests.kt

@ -29,21 +29,21 @@ class ControlledRadioGroupsTests {
assertEquals(0, controlledRadioGroups.size)
countOfRadio = 5
waitChanges()
waitForRecompositionComplete()
assertEquals(1, controlledRadioGroups.size)
assertTrue(controlledRadioGroups.keys.first() == "group1")
assertEquals(5, controlledRadioGroups["group1"]!!.size)
countOfRadio = 2
waitChanges()
waitForRecompositionComplete()
assertEquals(1, controlledRadioGroups.size)
assertTrue(controlledRadioGroups.keys.first() == "group1")
assertEquals(2, controlledRadioGroups["group1"]!!.size)
countOfRadio = 0
waitChanges()
waitForRecompositionComplete()
assertEquals(0, controlledRadioGroups.size)
}
@ -81,27 +81,27 @@ class ControlledRadioGroupsTests {
countOfRadioG1 = 5
countOfRadioG2 = 10
waitChanges()
waitForRecompositionComplete()
assertEquals(2, controlledRadioGroups.size)
assertEquals(5, controlledRadioGroups["group1"]!!.size)
assertEquals(10, controlledRadioGroups["group2"]!!.size)
countOfRadioG2 = 2
waitChanges()
waitForRecompositionComplete()
assertEquals(2, controlledRadioGroups.size)
assertEquals(5, controlledRadioGroups["group1"]!!.size)
assertEquals(2, controlledRadioGroups["group2"]!!.size)
countOfRadioG1 = 0
waitChanges()
waitForRecompositionComplete()
assertEquals(1, controlledRadioGroups.size)
assertEquals(2, controlledRadioGroups["group2"]!!.size)
countOfRadioG2 = 0
waitChanges()
waitForRecompositionComplete()
assertEquals(0, controlledRadioGroups.size)
}

8
web/core/src/jsTest/kotlin/DomSideEffectTests.kt

@ -62,7 +62,7 @@ class DomSideEffectTests {
i = 1
waitChanges()
waitForChanges()
assertEquals(
expected = 1,
actual = disposeCalls.size,
@ -102,7 +102,7 @@ class DomSideEffectTests {
showDiv = false
waitChanges()
waitForChanges()
assertEquals(1, onDisposeCalledTimes)
assertEquals(expected = "<div></div>", actual = root.outerHTML)
}
@ -123,7 +123,7 @@ class DomSideEffectTests {
}
DisposableRefEffect(key) {
effectsList.add("DisposableRefEffect")
onDispose { }
onDispose { }
}
}
}
@ -135,7 +135,7 @@ class DomSideEffectTests {
key = 2
recomposeScope?.invalidate()
waitForAnimationFrame()
waitForRecompositionComplete()
assertEquals(4, effectsList.size)
assertEquals("DisposableRefEffect", effectsList[0])

8
web/core/src/jsTest/kotlin/InlineStyleTests.kt

@ -38,7 +38,7 @@ class InlineStyleTests {
)
isRed = false
waitChanges()
waitForChanges()
assertEquals(
expected = "<span style=\"color: green;\">text</span>",
@ -69,7 +69,7 @@ class InlineStyleTests {
)
isRed = true
waitChanges()
waitForChanges()
assertEquals(
expected = "<span style=\"color: red;\">text</span>",
@ -100,7 +100,7 @@ class InlineStyleTests {
)
isRed = false
waitChanges()
waitForChanges()
assertEquals(
expected = "<span>text</span>",
@ -132,7 +132,7 @@ class InlineStyleTests {
repeat(4) {
isRed = !isRed
waitChanges()
waitForChanges()
val expected = if (isRed) {
"<span style=\"color: red;\">text</span>"

161
web/core/src/jsTest/kotlin/TestUtils.kt

@ -1,67 +1,151 @@
package org.jetbrains.compose.web.core.tests
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MonotonicFrameClock
import org.jetbrains.compose.web.renderComposable
import kotlinx.browser.document
import kotlinx.browser.window
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.promise
import kotlinx.dom.clear
import org.w3c.dom.*
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.time.DurationUnit
import kotlin.time.ExperimentalTime
import kotlin.time.toDuration
/**
* This class provides a set of utils methods to simplify compose-web tests.
* There is no need to create its instances manually.
* @see [runTest]
*/
class TestScope : CoroutineScope by MainScope() {
/**
* It's used as a parent element for the composition.
* It's added into the document's body automatically.
*/
val root = "div".asHtmlElement()
private val testScope = MainScope()
class TestScope : CoroutineScope by testScope {
private val recompositionCompleteEventsChannel = Channel<Unit>()
private val childrenIterator = root.children.asList().listIterator()
val root = "div".asHtmlElement()
init {
document.body!!.appendChild(root)
}
private fun onRecompositionComplete() {
launch {
recompositionCompleteEventsChannel.send(Unit)
}
}
/**
* Cleans up the [root] content.
* Creates a new composition with a given Composable [content].
*/
fun composition(content: @Composable () -> Unit) {
root.clear()
renderComposable(root) {
renderComposable(
root = root,
monotonicFrameClock = TestMonotonicClockImpl(
onRecomposeComplete = this::onRecompositionComplete
)
) {
content()
}
}
private val childrenIterator = root.children.asList().listIterator()
/**
* @return a reference to the next child element of the root.
* Subsequent calls will return next child reference every time.
*/
fun nextChild() = childrenIterator.next() as HTMLElement
/**
* @return a reference to current child.
* Calling this subsequently returns the same reference every time.
*/
fun currentChild() = root.children[childrenIterator.previousIndex()] as HTMLElement
suspend fun waitChanges() {
/**
* Suspends until [root] observes any change to its html.
*/
suspend fun waitForChanges() {
waitForChanges(root)
}
/**
* Suspends until element with [elementId] observes any change to its html.
*/
suspend fun waitForChanges(elementId: String) {
waitForChanges(document.getElementById(elementId) as HTMLElement)
}
/**
* Suspends until [element] observes any change to its html.
*/
suspend fun waitForChanges(element: HTMLElement) {
suspendCoroutine<Unit> { continuation ->
val observer = MutationObserver { mutations, observer ->
continuation.resume(Unit)
observer.disconnect()
}
observer.observe(element, MutationObserverOptions)
}
}
/**
* Suspends until recomposition completes.
*/
suspend fun waitForRecompositionComplete() {
recompositionCompleteEventsChannel.receive()
}
}
internal fun runTest(block: suspend TestScope.() -> Unit): dynamic {
/**
* Use this method to test compose-web components rendered using HTML.
* Declare states and make assertions in [block].
* Use [TestScope.composition] to define the code under test.
*
* For dynamic tests, use [TestScope.waitForRecompositionComplete]
* after changing state's values and before making assertions.
*
* @see [TestScope.composition]
* @see [TestScope.waitForRecompositionComplete]
* @see [TestScope.waitForChanges].
*
* Test example:
* ```
* @Test
* fun textChild() = runTest {
* var textState by mutableStateOf("inner text")
*
* composition {
* Div {
* Text(textState)
* }
* }
* assertEquals("<div>inner text</div>", root.innerHTML)
*
* textState = "new text"
* waitForRecompositionComplete()
*
* assertEquals("<div>new text</div>", root.innerHTML)
* }
* ```
*/
fun runTest(block: suspend TestScope.() -> Unit): dynamic {
val scope = TestScope()
return scope.promise { block(scope) }
}
internal fun runBlockingTest(
block: suspend CoroutineScope.() -> Unit
): dynamic = testScope.promise { this.block() }
internal fun String.asHtmlElement() = document.createElement(this) as HTMLElement
/* Currently, the recompositionRunner relies on AnimationFrame to run the recomposition and
applyChanges. Therefore we can use this method after updating the state and before making
assertions.
If tests get broken, then DefaultMonotonicFrameClock need to be checked if it still
uses window.requestAnimationFrame */
internal suspend fun waitForAnimationFrame() {
suspendCoroutine<Unit> { continuation ->
window.requestAnimationFrame {
continuation.resume(Unit)
}
}
}
fun String.asHtmlElement() = document.createElement(this) as HTMLElement
private object MutationObserverOptions : MutationObserverInit {
override var childList: Boolean? = true
@ -71,16 +155,19 @@ private object MutationObserverOptions : MutationObserverInit {
override var attributeOldValue: Boolean? = true
}
internal suspend fun waitForChanges(elementId: String) {
waitForChanges(document.getElementById(elementId) as HTMLElement)
}
@OptIn(ExperimentalTime::class)
private class TestMonotonicClockImpl(
private val onRecomposeComplete: () -> Unit
) : MonotonicFrameClock {
internal suspend fun waitForChanges(element: HTMLElement) {
suspendCoroutine<Unit> { continuation ->
val observer = MutationObserver { mutations, observer ->
continuation.resume(Unit)
observer.disconnect()
override suspend fun <R> withFrameNanos(
onFrame: (Long) -> R
): R = suspendCoroutine { continuation ->
window.requestAnimationFrame {
val duration = it.toDuration(DurationUnit.MILLISECONDS)
val result = onFrame(duration.inWholeNanoseconds)
continuation.resume(result)
onRecomposeComplete()
}
observer.observe(element, MutationObserverOptions)
}
}

14
web/core/src/jsTest/kotlin/elements/AttributesTests.kt

@ -170,7 +170,7 @@ class AttributesTests {
assertEquals(null, btn.getAttribute("disabled"))
disabled = true
waitChanges()
waitForChanges()
assertEquals("", btn.getAttribute("disabled"))
}
@ -211,7 +211,7 @@ class AttributesTests {
)
addClassD.value = false
waitChanges()
waitForChanges()
assertEquals(
expected = "c a b",
@ -239,7 +239,7 @@ class AttributesTests {
flag = false
waitChanges()
waitForChanges()
assertEquals("<div b=\"pp\" c=\"cc\"></div>", root.innerHTML)
}
@ -263,7 +263,7 @@ class AttributesTests {
assertEquals("<div>Text set using ref {}</div>", root.innerHTML)
flag = false
waitChanges()
waitForChanges()
assertEquals("", root.innerHTML)
}
@ -290,7 +290,7 @@ class AttributesTests {
assertEquals(false, disposed)
flag = false
waitChanges()
waitForChanges()
assertEquals("", root.innerHTML)
assertEquals(true, disposed)
@ -325,7 +325,7 @@ class AttributesTests {
assertEquals(0, refDisposeCounter)
counter++
waitChanges()
waitForChanges()
assertEquals("2<div></div>", root.innerHTML)
assertEquals(1, refInitCounter)
@ -351,7 +351,7 @@ class AttributesTests {
assertEquals("""<button style="color: red;">Button</button>""", root.innerHTML)
hasValue = true
waitForAnimationFrame()
waitForRecompositionComplete()
assertEquals("""<button style="color: red;" value="buttonValue">Button</button>""", root.innerHTML)
}

11
web/core/src/jsTest/kotlin/elements/InputsGenerateCorrectHtmlTests.kt

@ -6,7 +6,6 @@ import androidx.compose.runtime.getValue
import org.jetbrains.compose.web.attributes.*
import org.jetbrains.compose.web.core.tests.asHtmlElement
import org.jetbrains.compose.web.core.tests.runTest
import org.jetbrains.compose.web.core.tests.waitForAnimationFrame
import org.jetbrains.compose.web.dom.*
import org.jetbrains.compose.web.renderComposable
import org.w3c.dom.HTMLInputElement
@ -427,7 +426,7 @@ class InputsGenerateCorrectHtmlTests {
assertEquals("""<form autocomplete="on"></form>""", root.innerHTML)
autoCompleteEnabled = false
waitChanges()
waitForChanges()
assertEquals("""<form autocomplete="off"></form>""", root.innerHTML)
}
@ -453,7 +452,7 @@ class InputsGenerateCorrectHtmlTests {
assertEquals("text", (root.firstChild as HTMLInputElement).value)
state = ""
waitForAnimationFrame()
waitForRecompositionComplete()
assertEquals("", (root.firstChild as HTMLInputElement).value)
}
@ -469,7 +468,7 @@ class InputsGenerateCorrectHtmlTests {
assertEquals("", (root.firstChild as HTMLInputElement).value)
state = "text"
waitForAnimationFrame()
waitForRecompositionComplete()
assertEquals("text", (root.firstChild as HTMLInputElement).value)
}
@ -485,7 +484,7 @@ class InputsGenerateCorrectHtmlTests {
assertEquals("text", (root.firstChild as HTMLTextAreaElement).value)
state = ""
waitForAnimationFrame()
waitForRecompositionComplete()
assertEquals("", (root.firstChild as HTMLTextAreaElement).value)
}
@ -501,7 +500,7 @@ class InputsGenerateCorrectHtmlTests {
assertEquals("", (root.firstChild as HTMLTextAreaElement).value)
state = "text"
waitForAnimationFrame()
waitForRecompositionComplete()
assertEquals("text", (root.firstChild as HTMLTextAreaElement).value)
}

Loading…
Cancel
Save