First Commit
This commit is contained in:
33
tests/testutils/build.gradle.kts
Normal file
33
tests/testutils/build.gradle.kts
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.tests.testutils"
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.test.junit)
|
||||
implementation(libs.test.truth)
|
||||
implementation(libs.coroutines.test)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.services.toolbox.api)
|
||||
implementation(libs.test.turbine)
|
||||
implementation(libs.molecule.runtime)
|
||||
implementation(libs.androidx.compose.ui.test.junit)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import io.element.android.libraries.androidutils.metadata.isInDebug
|
||||
import org.junit.Assert.assertThrows
|
||||
|
||||
/**
|
||||
* Assert that the lambda throws only on debug mode.
|
||||
*/
|
||||
fun assertThrowsInDebug(lambda: () -> Any?) {
|
||||
if (isInDebug) {
|
||||
assertThrows(IllegalStateException::class.java) {
|
||||
lambda()
|
||||
}
|
||||
} else {
|
||||
lambda()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
class EnsureCalledOnce : () -> Unit {
|
||||
private var counter = 0
|
||||
override fun invoke() {
|
||||
counter++
|
||||
}
|
||||
|
||||
fun assertSuccess() {
|
||||
if (counter != 1) {
|
||||
throw AssertionError("Expected to be called once, but was called $counter times")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EnsureCalledTimes(val times: Int) : () -> Unit {
|
||||
private var counter = 0
|
||||
override fun invoke() {
|
||||
counter++
|
||||
}
|
||||
|
||||
fun assertSuccess() {
|
||||
if (counter != times) {
|
||||
throw AssertionError("Expected to be called $times, but was called $counter times")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ensureCalledOnce(block: (callback: () -> Unit) -> Unit) {
|
||||
val callback = EnsureCalledOnce()
|
||||
block(callback)
|
||||
callback.assertSuccess()
|
||||
}
|
||||
|
||||
fun ensureCalledTimes(times: Int, block: (callback: () -> Unit) -> Unit) {
|
||||
val callback = EnsureCalledTimes(times)
|
||||
block(callback)
|
||||
callback.assertSuccess()
|
||||
}
|
||||
|
||||
class EnsureCalledOnceWithParam<T, R>(
|
||||
private val expectedParam: T,
|
||||
private val result: R,
|
||||
) : (T) -> R {
|
||||
private var counter = 0
|
||||
override fun invoke(p1: T): R {
|
||||
if (p1 != expectedParam) {
|
||||
throw AssertionError("Expected to be called with $expectedParam, but was called with $p1")
|
||||
}
|
||||
counter++
|
||||
return result
|
||||
}
|
||||
|
||||
fun assertSuccess() {
|
||||
if (counter != 1) {
|
||||
throw AssertionError("Expected to be called once, but was called $counter times")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EnsureCalledOnceWithTwoParams<T, U>(
|
||||
private val expectedParam1: T,
|
||||
private val expectedParam2: U,
|
||||
) : (T, U) -> Unit {
|
||||
private var counter = 0
|
||||
override fun invoke(p1: T, p2: U) {
|
||||
if (p1 != expectedParam1 || p2 != expectedParam2) {
|
||||
throw AssertionError("Expected to be called with $expectedParam1 and $expectedParam2, but was called with $p1 and $p2")
|
||||
}
|
||||
counter++
|
||||
}
|
||||
|
||||
fun assertSuccess() {
|
||||
if (counter != 1) {
|
||||
throw AssertionError("Expected to be called once, but was called $counter times")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EnsureCalledOnceWithTwoParamsAndResult<T, U, R>(
|
||||
private val expectedParam1: T,
|
||||
private val expectedParam2: U,
|
||||
private val result: R,
|
||||
) : (T, U) -> R {
|
||||
private var counter = 0
|
||||
override fun invoke(p1: T, p2: U): R {
|
||||
if (p1 != expectedParam1 || p2 != expectedParam2) {
|
||||
throw AssertionError("Expected to be called with $expectedParam1 and $expectedParam2, but was called with $p1 and $p2")
|
||||
}
|
||||
counter++
|
||||
return result
|
||||
}
|
||||
|
||||
fun assertSuccess() {
|
||||
if (counter != 1) {
|
||||
throw AssertionError("Expected to be called once, but was called $counter times")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut for [<T, R> ensureCalledOnceWithParam] with Unit result.
|
||||
*/
|
||||
fun <T> ensureCalledOnceWithParam(param: T, block: (callback: EnsureCalledOnceWithParam<T, Unit>) -> Unit) {
|
||||
ensureCalledOnceWithParam(param, block, Unit)
|
||||
}
|
||||
|
||||
fun <T, R> ensureCalledOnceWithParam(param: T, block: (callback: EnsureCalledOnceWithParam<T, R>) -> R, result: R) {
|
||||
val callback = EnsureCalledOnceWithParam(param, result)
|
||||
block(callback)
|
||||
callback.assertSuccess()
|
||||
}
|
||||
|
||||
fun <P1, P2> ensureCalledOnceWithTwoParams(param1: P1, param2: P2, block: (callback: EnsureCalledOnceWithTwoParams<P1, P2>) -> Unit) {
|
||||
val callback = EnsureCalledOnceWithTwoParams(param1, param2)
|
||||
block(callback)
|
||||
callback.assertSuccess()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class EnsureNeverCalled : () -> Unit {
|
||||
override fun invoke() {
|
||||
lambdaError()
|
||||
}
|
||||
}
|
||||
|
||||
class EnsureNeverCalledWithParam<T> : (T) -> Unit {
|
||||
override fun invoke(p1: T) {
|
||||
lambdaError("Should not be called and is called with $p1")
|
||||
}
|
||||
}
|
||||
|
||||
class EnsureNeverCalledWithParamAndResult<T, R> : (T) -> R {
|
||||
override fun invoke(p1: T): R {
|
||||
lambdaError("Should not be called and is called with $p1")
|
||||
}
|
||||
}
|
||||
|
||||
class EnsureNeverCalledWithTwoParams<T, U> : (T, U) -> Unit {
|
||||
override fun invoke(p1: T, p2: U) {
|
||||
lambdaError("Should not be called and is called with $p1 and $p2")
|
||||
}
|
||||
}
|
||||
|
||||
class EnsureNeverCalledWithTwoParamsAndResult<T, U, R> : (T, U) -> R {
|
||||
override fun invoke(p1: T, p2: U): R {
|
||||
lambdaError("Should not be called and is called with $p1 and $p2")
|
||||
}
|
||||
}
|
||||
|
||||
class EnsureNeverCalledWithThreeParams<T, U, V> : (T, U, V) -> Unit {
|
||||
override fun invoke(p1: T, p2: U, p3: V) {
|
||||
lambdaError("Should not be called and is called with $p1, $p2 and $p3")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
||||
class EventsRecorder<T>(
|
||||
private val expectEvents: Boolean = true
|
||||
) : (T) -> Unit {
|
||||
private val events = mutableListOf<T>()
|
||||
|
||||
override fun invoke(event: T) {
|
||||
if (expectEvents) {
|
||||
events.add(event)
|
||||
} else {
|
||||
throw AssertionError("Unexpected event: $event")
|
||||
}
|
||||
}
|
||||
|
||||
fun assertEmpty() {
|
||||
assertThat(events).isEmpty()
|
||||
}
|
||||
|
||||
fun assertSingle(event: T) {
|
||||
assertList(listOf(event))
|
||||
}
|
||||
|
||||
fun assertList(expectedEvents: List<T>) {
|
||||
assertThat(events).isEqualTo(expectedEvents)
|
||||
}
|
||||
|
||||
fun assertSize(size: Int) {
|
||||
assertThat(events.size).isEqualTo(size)
|
||||
}
|
||||
|
||||
fun assertTrue(index: Int, predicate: (T) -> Boolean) {
|
||||
assertThat(predicate(events[index])).isTrue()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
events.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
|
||||
class InstrumentationStringProvider : StringProvider {
|
||||
private val resource = InstrumentationRegistry.getInstrumentation().context.resources
|
||||
override fun getString(resId: Int): String {
|
||||
return resource.getString(resId)
|
||||
}
|
||||
|
||||
override fun getString(resId: Int, vararg formatArgs: Any?): String {
|
||||
return resource.getString(resId, *formatArgs)
|
||||
}
|
||||
|
||||
override fun getQuantityString(resId: Int, quantity: Int, vararg formatArgs: Any?): String {
|
||||
return resource.getQuantityString(resId, quantity, *formatArgs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Workaround for https://github.com/cashapp/molecule/issues/249.
|
||||
* This functions should be removed/deprecated right after we find a proper fix.
|
||||
*/
|
||||
suspend inline fun <T> simulateLongTask(lambda: () -> T): T {
|
||||
delay(1)
|
||||
return lambda()
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be used for testing events in Presenter, where the event does not emit new state.
|
||||
* If the (virtual) timeout is passed, we release the latch manually.
|
||||
*/
|
||||
suspend fun awaitWithLatch(timeout: Duration = 300.milliseconds, block: (CompletableDeferred<Unit>) -> Unit) {
|
||||
val latch = CompletableDeferred<Unit>()
|
||||
try {
|
||||
withTimeout(timeout) {
|
||||
latch.also(block).await()
|
||||
}
|
||||
} catch (exception: TimeoutCancellationException) {
|
||||
latch.complete(Unit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class MutablePresenter<State>(initialState: State) : Presenter<State> {
|
||||
private val stateFlow = MutableStateFlow(initialState)
|
||||
|
||||
fun updateState(state: State) {
|
||||
stateFlow.value = state
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): State {
|
||||
return stateFlow.collectAsState().value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.TurbineTestContext
|
||||
import app.cash.turbine.test
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import org.junit.Assert.fail
|
||||
import kotlin.time.Duration
|
||||
|
||||
suspend fun <State> Presenter<State>.test(
|
||||
timeout: Duration? = null,
|
||||
name: String? = null,
|
||||
validate: suspend TurbineTestContext<State>.() -> Unit,
|
||||
) {
|
||||
try {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
present()
|
||||
}.test(timeout, name, validate)
|
||||
} catch (t: Throwable) {
|
||||
if (t::class.simpleName == "KotlinReflectionInternalError") {
|
||||
// Give a more explicit error to the developer
|
||||
fail("""
|
||||
It looks like you have an unconsumed event in your test.
|
||||
If you get this error, it means that your test is missing to consume one or several events.
|
||||
You can fix by consuming and check the event with `awaitItem()`, or you can also invoke
|
||||
`cancelAndIgnoreRemainingEvents()`.
|
||||
""".trimIndent())
|
||||
}
|
||||
throw t
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import app.cash.turbine.Event
|
||||
import app.cash.turbine.ReceiveTurbine
|
||||
import app.cash.turbine.withTurbineTimeout
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Consume all items until timeout is reached waiting for an event or we receive terminal event.
|
||||
* The timeout is applied for each event.
|
||||
* @return the list of consumed items.
|
||||
*/
|
||||
suspend fun <T : Any> ReceiveTurbine<T>.consumeItemsUntilTimeout(timeout: Duration = 100.milliseconds): List<T> {
|
||||
return consumeItemsUntilPredicate(timeout, ignoreTimeoutError = true) { false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume all items which are emitted sequentially.
|
||||
* Use the smallest timeout possible internally to avoid wasting time.
|
||||
* Same as calling skipItems(x) and then awaitItem() but without assumption on the number of items.
|
||||
* @return the last item emitted.
|
||||
*/
|
||||
suspend fun <T : Any> ReceiveTurbine<T>.awaitLastSequentialItem(): T {
|
||||
return consumeItemsUntilTimeout(1.milliseconds).last()
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume items until predicate is true, or timeout is reached waiting for an event, or we receive terminal event.
|
||||
* The timeout is applied for each event.
|
||||
* @return the list of consumed items.
|
||||
*/
|
||||
suspend fun <T : Any> ReceiveTurbine<T>.consumeItemsUntilPredicate(
|
||||
timeout: Duration = 3.seconds,
|
||||
ignoreTimeoutError: Boolean = false,
|
||||
predicate: (T) -> Boolean,
|
||||
): List<T> {
|
||||
val items = ArrayList<T>()
|
||||
var exitLoop = false
|
||||
try {
|
||||
while (!exitLoop) {
|
||||
when (val event = withTurbineTimeout(timeout) { awaitEvent() }) {
|
||||
is Event.Item<T> -> {
|
||||
items.add(event.value)
|
||||
exitLoop = predicate(event.value)
|
||||
}
|
||||
Event.Complete -> error("Unexpected complete")
|
||||
is Event.Error -> throw event.throwable
|
||||
}
|
||||
}
|
||||
} catch (assertionError: AssertionError) {
|
||||
// TurbineAssertionError is internal :/, so rely on the message
|
||||
if (assertionError.message?.startsWith("No value produced in").orFalse() && ignoreTimeoutError) {
|
||||
// Timeout, ignore
|
||||
} else {
|
||||
throw assertionError
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.rules.TestRule
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
object RobolectricDispatcherCleaner {
|
||||
// HACK: Workaround for https://github.com/robolectric/robolectric/issues/7055#issuecomment-1551119229
|
||||
fun clearAndroidUiDispatcher(pkg: String = "androidx.compose.ui.platform") {
|
||||
val clazz = javaClass.classLoader!!.loadClass("$pkg.AndroidUiDispatcher")
|
||||
val combinedContextClass = javaClass.classLoader!!.loadClass("kotlin.coroutines.CombinedContext")
|
||||
val companionClazz = clazz.getDeclaredField("Companion").get(clazz)
|
||||
val combinedContext = companionClazz.javaClass.getDeclaredMethod("getMain")
|
||||
.invoke(companionClazz) as CoroutineContext
|
||||
val androidUiDispatcher = combinedContextClass.getDeclaredField("element")
|
||||
.apply { isAccessible = true }
|
||||
.get(combinedContext)
|
||||
.let { clazz.cast(it) }
|
||||
var scheduledFrameDispatch = clazz.getDeclaredField("scheduledFrameDispatch")
|
||||
.apply { isAccessible = true }
|
||||
.getBoolean(androidUiDispatcher)
|
||||
var scheduledTrampolineDispatch = clazz.getDeclaredField("scheduledTrampolineDispatch")
|
||||
.apply { isAccessible = true }
|
||||
.getBoolean(androidUiDispatcher)
|
||||
val dispatchCallback = clazz.getDeclaredField("dispatchCallback")
|
||||
.apply { isAccessible = true }
|
||||
.get(androidUiDispatcher) as Runnable
|
||||
if (scheduledFrameDispatch || scheduledTrampolineDispatch) {
|
||||
dispatchCallback.run()
|
||||
scheduledFrameDispatch = clazz.getDeclaredField("scheduledFrameDispatch")
|
||||
.apply { isAccessible = true }
|
||||
.getBoolean(androidUiDispatcher)
|
||||
scheduledTrampolineDispatch = clazz.getDeclaredField("scheduledTrampolineDispatch")
|
||||
.apply { isAccessible = true }
|
||||
.getBoolean(androidUiDispatcher)
|
||||
}
|
||||
assertFalse(scheduledFrameDispatch)
|
||||
assertFalse(scheduledTrampolineDispatch)
|
||||
}
|
||||
}
|
||||
|
||||
fun <R : TestRule, A : ComponentActivity> AndroidComposeTestRule<R, A>.setSafeContent(
|
||||
clearAndroidUiDispatcher: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
if (clearAndroidUiDispatcher) {
|
||||
RobolectricDispatcherCleaner.clearAndroidUiDispatcher()
|
||||
}
|
||||
setContent {
|
||||
CompositionLocalProvider(LocalUiTestMode provides true) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasContentDescription
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.onFirst
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import org.junit.rules.TestRule
|
||||
|
||||
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.clickOn(@StringRes res: Int) {
|
||||
val text = activity.getString(res)
|
||||
onNode(hasText(text) and hasClickAction())
|
||||
.performClick()
|
||||
}
|
||||
|
||||
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.clickOnFirst(@StringRes res: Int) {
|
||||
val text = activity.getString(res)
|
||||
onAllNodes(hasText(text) and hasClickAction()).onFirst().performClick()
|
||||
}
|
||||
|
||||
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.clickOnLast(@StringRes res: Int) {
|
||||
val text = activity.getString(res)
|
||||
onAllNodes(hasText(text) and hasClickAction()).onFirst().performClick()
|
||||
}
|
||||
|
||||
/**
|
||||
* Press the back button in the app bar.
|
||||
*/
|
||||
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.pressBack() {
|
||||
val text = activity.getString(CommonStrings.action_back)
|
||||
onNode(hasContentDescription(text)).performClick()
|
||||
}
|
||||
|
||||
/**
|
||||
* Press the back key.
|
||||
*/
|
||||
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.pressBackKey() {
|
||||
activity.onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
|
||||
fun SemanticsNodeInteractionsProvider.pressTag(tag: String) {
|
||||
onNode(hasTestTag(tag)).performClick()
|
||||
}
|
||||
|
||||
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.assertNoNodeWithText(@StringRes res: Int) {
|
||||
val text = activity.getString(res)
|
||||
onNodeWithText(text).assertDoesNotExist()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
|
||||
@Composable
|
||||
fun withConfigurationAndContext(content: @Composable () -> Any?): Any? {
|
||||
var result: Any? = null
|
||||
CompositionLocalProvider(
|
||||
LocalConfiguration provides Configuration(),
|
||||
LocalContext provides ApplicationProvider.getApplicationContext(),
|
||||
) {
|
||||
result = content()
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
|
||||
/**
|
||||
* Create a [CoroutineDispatchers] instance for testing.
|
||||
*
|
||||
* @param useUnconfinedTestDispatcher If true, use [UnconfinedTestDispatcher] for all dispatchers.
|
||||
* If false, use [StandardTestDispatcher] for all dispatchers.
|
||||
*/
|
||||
fun TestScope.testCoroutineDispatchers(
|
||||
useUnconfinedTestDispatcher: Boolean = false,
|
||||
): CoroutineDispatchers = when (useUnconfinedTestDispatcher) {
|
||||
true -> CoroutineDispatchers(
|
||||
io = UnconfinedTestDispatcher(testScheduler),
|
||||
computation = UnconfinedTestDispatcher(testScheduler),
|
||||
main = UnconfinedTestDispatcher(testScheduler),
|
||||
)
|
||||
false -> CoroutineDispatchers(
|
||||
io = StandardTestDispatcher(testScheduler),
|
||||
computation = StandardTestDispatcher(testScheduler),
|
||||
main = StandardTestDispatcher(testScheduler),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import timber.log.Timber
|
||||
|
||||
fun plantTestTimber() {
|
||||
Timber.plant(object : Timber.Tree() {
|
||||
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
|
||||
println("$tag: $message")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
suspend fun waitForPredicate(
|
||||
delayBetweenAttemptsMillis: Long = 1,
|
||||
maxNumberOfAttempts: Int = 20,
|
||||
predicate: () -> Boolean,
|
||||
) {
|
||||
for (i in 0..maxNumberOfAttempts) {
|
||||
if (predicate()) return
|
||||
if (i < maxNumberOfAttempts) delay(delayBetweenAttemptsMillis)
|
||||
}
|
||||
throw AssertionError("Predicate was not true after $maxNumberOfAttempts attempts")
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.Description
|
||||
import org.junit.runners.model.Statement
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* moleculeFlow can take time to initialise during the first test of any given
|
||||
* test class.
|
||||
*
|
||||
* Applying this test rule ensures that the slow initialisation is not done
|
||||
* inside runTest which has a short default timeout.
|
||||
*/
|
||||
class WarmUpRule : TestRule {
|
||||
companion object {
|
||||
init {
|
||||
warmUpMolecule()
|
||||
}
|
||||
}
|
||||
|
||||
override fun apply(base: Statement, description: Description): Statement = base
|
||||
}
|
||||
|
||||
private fun warmUpMolecule() {
|
||||
runTest(timeout = 60.seconds) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
// Do nothing
|
||||
}.test {
|
||||
awaitItem() // Await a Unit composition
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.InternalComposeApi
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.currentComposer
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.TurbineTestContext
|
||||
import app.cash.turbine.test
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
|
||||
/**
|
||||
* Composable that provides a fake [LifecycleOwner] to the composition.
|
||||
*/
|
||||
@OptIn(InternalComposeApi::class)
|
||||
@Stable
|
||||
@Composable
|
||||
fun <T> withFakeLifecycleOwner(
|
||||
lifecycleOwner: FakeLifecycleOwner = FakeLifecycleOwner(),
|
||||
block: @Composable () -> T
|
||||
): T {
|
||||
currentComposer.startProvider(LocalLifecycleOwner provides lifecycleOwner)
|
||||
val state = block()
|
||||
currentComposer.endProvider()
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a [Presenter] with a fake [LifecycleOwner].
|
||||
*/
|
||||
suspend fun <T> Presenter<T>.testWithLifecycleOwner(
|
||||
lifecycleOwner: FakeLifecycleOwner = FakeLifecycleOwner(),
|
||||
block: suspend TurbineTestContext<T>.() -> Unit
|
||||
) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
withFakeLifecycleOwner(lifecycleOwner) {
|
||||
present()
|
||||
}
|
||||
}.test(validate = block)
|
||||
}
|
||||
|
||||
@SuppressLint("VisibleForTests")
|
||||
class FakeLifecycleOwner(initialState: Lifecycle.State? = null) : LifecycleOwner {
|
||||
override val lifecycle: Lifecycle = LifecycleRegistry.createUnsafe(this)
|
||||
|
||||
init {
|
||||
initialState?.let { givenState(it) }
|
||||
}
|
||||
|
||||
fun givenState(state: Lifecycle.State) {
|
||||
(lifecycle as LifecycleRegistry).currentState = state
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils.fake
|
||||
|
||||
import android.net.Uri
|
||||
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeTemporaryUriDeleter(
|
||||
val deleteLambda: (uri: Uri?) -> Unit = { lambdaError() }
|
||||
) : TemporaryUriDeleter {
|
||||
override fun delete(uri: Uri?) {
|
||||
deleteLambda(uri)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils.lambda
|
||||
|
||||
fun assert(lambdaRecorder: LambdaRecorder): LambdaRecorderAssertions {
|
||||
return lambdaRecorder.assertions()
|
||||
}
|
||||
|
||||
class LambdaRecorderAssertions internal constructor(
|
||||
private val parametersSequence: List<List<Any?>>,
|
||||
) {
|
||||
fun isCalledOnce(): CalledOnceParametersAssertions {
|
||||
return CalledOnceParametersAssertions(
|
||||
assertions = isCalledExactly(1)
|
||||
)
|
||||
}
|
||||
|
||||
fun isNeverCalled() {
|
||||
isCalledExactly(0)
|
||||
}
|
||||
|
||||
fun isCalledExactly(times: Int): ParametersAssertions {
|
||||
if (parametersSequence.size != times) {
|
||||
throw AssertionError("Expected to be called $times, but was called ${parametersSequence.size} times")
|
||||
}
|
||||
return ParametersAssertions(parametersSequence)
|
||||
}
|
||||
}
|
||||
|
||||
class CalledOnceParametersAssertions internal constructor(private val assertions: ParametersAssertions) {
|
||||
fun with(vararg matchers: ParameterMatcher) {
|
||||
assertions.withSequence(matchers.toList())
|
||||
}
|
||||
|
||||
fun withNoParameter() {
|
||||
assertions.withNoParameter()
|
||||
}
|
||||
}
|
||||
|
||||
class ParametersAssertions internal constructor(
|
||||
private val parametersSequence: List<List<Any?>>
|
||||
) {
|
||||
fun withSequence(vararg matchersSequence: List<ParameterMatcher>) {
|
||||
if (parametersSequence.size != matchersSequence.size) {
|
||||
throw AssertionError("Lambda was called ${parametersSequence.size} times, but only ${matchersSequence.size} assertions were provided")
|
||||
}
|
||||
parametersSequence.zip(matchersSequence).forEachIndexed { invocationIndex, (parameters, matchers) ->
|
||||
if (parameters.size != matchers.size) {
|
||||
throw AssertionError("Expected ${matchers.size} parameters, but got ${parameters.size} parameters during invocation #$invocationIndex")
|
||||
}
|
||||
parameters.zip(matchers).forEachIndexed { paramIndex, (param, matcher) ->
|
||||
if (!matcher.match(param)) {
|
||||
throw AssertionError(
|
||||
"Parameter #$paramIndex does not match the expected value (actual=$param,expected=$matcher) during invocation #$invocationIndex"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun withNoParameter() {
|
||||
if (parametersSequence.any { it.isNotEmpty() }) {
|
||||
throw AssertionError("Expected no parameters, but got some")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils.lambda
|
||||
|
||||
fun lambdaError(
|
||||
message: String = "This lambda should never be called."
|
||||
): Nothing {
|
||||
throw AssertionError(message)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils.lambda
|
||||
|
||||
/**
|
||||
* A recorder that can be used to record the parameters of lambda invocation.
|
||||
*/
|
||||
abstract class LambdaRecorder internal constructor(
|
||||
private val assertNoInvocation: Boolean,
|
||||
) {
|
||||
private val parametersSequence: MutableList<List<Any?>> = mutableListOf()
|
||||
|
||||
internal fun onInvoke(vararg params: Any?) {
|
||||
if (assertNoInvocation) {
|
||||
lambdaError()
|
||||
}
|
||||
parametersSequence.add(params.toList())
|
||||
}
|
||||
|
||||
fun assertions(): LambdaRecorderAssertions {
|
||||
return LambdaRecorderAssertions(parametersSequence = parametersSequence)
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified R> lambdaRecorder(
|
||||
ensureNeverCalled: Boolean = false,
|
||||
noinline block: () -> R
|
||||
): LambdaNoParamRecorder<R> {
|
||||
return LambdaNoParamRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
inline fun <reified T, reified R> lambdaRecorder(
|
||||
ensureNeverCalled: Boolean = false,
|
||||
noinline block: (T) -> R
|
||||
): LambdaOneParamRecorder<T, R> {
|
||||
return LambdaOneParamRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
inline fun <reified T1, reified T2, reified R> lambdaRecorder(
|
||||
ensureNeverCalled: Boolean = false,
|
||||
noinline block: (T1, T2) -> R
|
||||
): LambdaTwoParamsRecorder<T1, T2, R> {
|
||||
return LambdaTwoParamsRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
inline fun <reified T1, reified T2, reified T3, reified R> lambdaRecorder(
|
||||
ensureNeverCalled: Boolean = false,
|
||||
noinline block: (T1, T2, T3) -> R
|
||||
): LambdaThreeParamsRecorder<T1, T2, T3, R> {
|
||||
return LambdaThreeParamsRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
inline fun <reified T1, reified T2, reified T3, reified T4, reified R> lambdaRecorder(
|
||||
ensureNeverCalled: Boolean = false,
|
||||
noinline block: (T1, T2, T3, T4) -> R
|
||||
): LambdaFourParamsRecorder<T1, T2, T3, T4, R> {
|
||||
return LambdaFourParamsRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
inline fun <reified T1, reified T2, reified T3, reified T4, reified T5, reified R> lambdaRecorder(
|
||||
ensureNeverCalled: Boolean = false,
|
||||
noinline block: (T1, T2, T3, T4, T5) -> R
|
||||
): LambdaFiveParamsRecorder<T1, T2, T3, T4, T5, R> {
|
||||
return LambdaFiveParamsRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
inline fun <reified T1, reified T2, reified T3, reified T4, reified T5, reified T6, reified R> lambdaRecorder(
|
||||
ensureNeverCalled: Boolean = false,
|
||||
noinline block: (T1, T2, T3, T4, T5, T6) -> R
|
||||
): LambdaSixParamsRecorder<T1, T2, T3, T4, T5, T6, R> {
|
||||
return LambdaSixParamsRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
inline fun <reified T1, reified T2, reified T3, reified T4, reified T5, reified T6, reified T7, reified R> lambdaRecorder(
|
||||
ensureNeverCalled: Boolean = false,
|
||||
noinline block: (T1, T2, T3, T4, T5, T6, T7) -> R
|
||||
): LambdaSevenParamsRecorder<T1, T2, T3, T4, T5, T6, T7, R> {
|
||||
return LambdaSevenParamsRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
inline fun <reified T1, reified T2, reified T3, reified T4, reified T5, reified T6, reified T7, reified T8, reified R> lambdaRecorder(
|
||||
ensureNeverCalled: Boolean = false,
|
||||
noinline block: (T1, T2, T3, T4, T5, T6, T7, T8) -> R
|
||||
): LambdaEightParamsRecorder<T1, T2, T3, T4, T5, T6, T7, T8, R> {
|
||||
return LambdaEightParamsRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
inline fun <reified R> lambdaAnyRecorder(
|
||||
ensureNeverCalled: Boolean = false,
|
||||
noinline block: (List<Any?>) -> R
|
||||
): LambdaListAnyParamsRecorder<R> {
|
||||
return LambdaListAnyParamsRecorder(ensureNeverCalled, block)
|
||||
}
|
||||
|
||||
class LambdaNoParamRecorder<out R>(ensureNeverCalled: Boolean, val block: () -> R) : LambdaRecorder(ensureNeverCalled), () -> R {
|
||||
override fun invoke(): R {
|
||||
onInvoke()
|
||||
return block()
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaOneParamRecorder<in T, out R>(ensureNeverCalled: Boolean, val block: (T) -> R) : LambdaRecorder(ensureNeverCalled), (T) -> R {
|
||||
override fun invoke(p: T): R {
|
||||
onInvoke(p)
|
||||
return block(p)
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaTwoParamsRecorder<in T1, in T2, out R>(ensureNeverCalled: Boolean, val block: (T1, T2) -> R) : LambdaRecorder(ensureNeverCalled), (T1, T2) -> R {
|
||||
override fun invoke(p1: T1, p2: T2): R {
|
||||
onInvoke(p1, p2)
|
||||
return block(p1, p2)
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaThreeParamsRecorder<in T1, in T2, in T3, out R>(ensureNeverCalled: Boolean, val block: (T1, T2, T3) -> R) : LambdaRecorder(
|
||||
ensureNeverCalled
|
||||
), (T1, T2, T3) -> R {
|
||||
override fun invoke(p1: T1, p2: T2, p3: T3): R {
|
||||
onInvoke(p1, p2, p3)
|
||||
return block(p1, p2, p3)
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaFourParamsRecorder<in T1, in T2, in T3, in T4, out R>(ensureNeverCalled: Boolean, val block: (T1, T2, T3, T4) -> R) : LambdaRecorder(
|
||||
ensureNeverCalled
|
||||
), (T1, T2, T3, T4) -> R {
|
||||
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4): R {
|
||||
onInvoke(p1, p2, p3, p4)
|
||||
return block(p1, p2, p3, p4)
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaFiveParamsRecorder<in T1, in T2, in T3, in T4, in T5, out R>(
|
||||
ensureNeverCalled: Boolean,
|
||||
val block: (T1, T2, T3, T4, T5) -> R,
|
||||
) : LambdaRecorder(
|
||||
ensureNeverCalled
|
||||
), (T1, T2, T3, T4, T5) -> R {
|
||||
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4, p5: T5): R {
|
||||
onInvoke(p1, p2, p3, p4, p5)
|
||||
return block(p1, p2, p3, p4, p5)
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaSixParamsRecorder<in T1, in T2, in T3, in T4, in T5, in T6, out R>(
|
||||
ensureNeverCalled: Boolean,
|
||||
val block: (T1, T2, T3, T4, T5, T6) -> R,
|
||||
) : LambdaRecorder(ensureNeverCalled), (T1, T2, T3, T4, T5, T6) -> R {
|
||||
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4, p5: T5, p6: T6): R {
|
||||
onInvoke(p1, p2, p3, p4, p5, p6)
|
||||
return block(p1, p2, p3, p4, p5, p6)
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaSevenParamsRecorder<in T1, in T2, in T3, in T4, in T5, in T6, in T7, out R>(
|
||||
ensureNeverCalled: Boolean,
|
||||
val block: (T1, T2, T3, T4, T5, T6, T7) -> R,
|
||||
) : LambdaRecorder(ensureNeverCalled), (T1, T2, T3, T4, T5, T6, T7) -> R {
|
||||
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4, p5: T5, p6: T6, p7: T7): R {
|
||||
onInvoke(p1, p2, p3, p4, p5, p6, p7)
|
||||
return block(p1, p2, p3, p4, p5, p6, p7)
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaEightParamsRecorder<in T1, in T2, in T3, in T4, in T5, in T6, in T7, in T8, out R>(
|
||||
ensureNeverCalled: Boolean,
|
||||
val block: (T1, T2, T3, T4, T5, T6, T7, T8) -> R,
|
||||
) : LambdaRecorder(ensureNeverCalled), (T1, T2, T3, T4, T5, T6, T7, T8) -> R {
|
||||
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4, p5: T5, p6: T6, p7: T7, p8: T8): R {
|
||||
onInvoke(p1, p2, p3, p4, p5, p6, p7, p8)
|
||||
return block(p1, p2, p3, p4, p5, p6, p7, p8)
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaNineParamsRecorder<in T1, in T2, in T3, in T4, in T5, in T6, in T7, in T8, in T9, out R>(
|
||||
ensureNeverCalled: Boolean,
|
||||
val block: (T1, T2, T3, T4, T5, T6, T7, T8, T9) -> R,
|
||||
) : LambdaRecorder(ensureNeverCalled), (T1, T2, T3, T4, T5, T6, T7, T8, T9) -> R {
|
||||
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4, p5: T5, p6: T6, p7: T7, p8: T8, p9: T9): R {
|
||||
onInvoke(p1, p2, p3, p4, p5, p6, p7, p8, p9)
|
||||
return block(p1, p2, p3, p4, p5, p6, p7, p8, p9)
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaListAnyParamsRecorder<out R>(
|
||||
ensureNeverCalled: Boolean,
|
||||
val block: (List<Any?>) -> R,
|
||||
) : LambdaRecorder(ensureNeverCalled), (List<Any?>) -> R {
|
||||
override fun invoke(p: List<Any?>): R {
|
||||
onInvoke(*p.toTypedArray())
|
||||
return block(p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils.lambda
|
||||
|
||||
/**
|
||||
* A matcher that can be used to match parameters in lambda calls.
|
||||
* This is useful to assert that a lambda has been called with specific parameters.
|
||||
*/
|
||||
interface ParameterMatcher {
|
||||
fun match(param: Any?): Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A matcher that matches a specific value.
|
||||
* Can be used to assert that a lambda has been called with a specific value.
|
||||
*/
|
||||
fun <T> value(expectedValue: T) = object : ParameterMatcher {
|
||||
override fun match(param: Any?) = param == expectedValue
|
||||
override fun toString(): String = "value($expectedValue)"
|
||||
}
|
||||
|
||||
/**
|
||||
* A matcher that matches a value based on a condition.
|
||||
* Can be used to assert that a lambda has been called with a value that satisfies a specific condition.
|
||||
*/
|
||||
fun <T> matching(check: (T) -> Boolean) = object : ParameterMatcher {
|
||||
override fun match(param: Any?): Boolean {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return (param as? T)?.let { check(it) } ?: false
|
||||
}
|
||||
override fun toString(): String = "matching(condition)"
|
||||
}
|
||||
|
||||
/**
|
||||
* A matcher that matches any value.
|
||||
* Can be used when we don't care about the value of a parameter.
|
||||
*/
|
||||
fun any() = object : ParameterMatcher {
|
||||
override fun match(param: Any?) = true
|
||||
override fun toString(): String = "any()"
|
||||
}
|
||||
|
||||
/**
|
||||
* A matcher that matches any non null value
|
||||
* Can be used when we don't care about the value of a parameter, just about its nullability.
|
||||
*/
|
||||
fun nonNull() = object : ParameterMatcher {
|
||||
override fun match(param: Any?) = param != null
|
||||
override fun toString(): String = "nonNull()"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils.node
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.EmptyNodeView
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import io.element.android.libraries.architecture.AssistedNodeFactory
|
||||
import io.element.android.libraries.architecture.NodeFactoriesBindings
|
||||
import io.element.android.libraries.di.DependencyInjectionGraphOwner
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* A parent Node that can create a single type of child Node using the provided factory.
|
||||
* This is useful to test a Feature entry point, by providing a fake parent that can create a
|
||||
* child Node.
|
||||
*/
|
||||
class TestParentNode<Child : Node>(
|
||||
private val childNodeClass: KClass<out Node>,
|
||||
private val childNodeFactory: (buildContext: BuildContext, plugins: List<Plugin>) -> Child,
|
||||
) : DependencyInjectionGraphOwner,
|
||||
Node(
|
||||
buildContext = BuildContext.Companion.root(savedStateMap = null),
|
||||
plugins = emptyList(),
|
||||
view = EmptyNodeView,
|
||||
) {
|
||||
override val graph: NodeFactoriesBindings = NodeFactoriesBindings {
|
||||
mapOf(
|
||||
childNodeClass to AssistedNodeFactory { buildContext, plugins ->
|
||||
childNodeFactory(buildContext, plugins)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Inline factory function with reified type parameter
|
||||
inline fun <reified Child : Node> create(
|
||||
noinline childNodeFactory: (buildContext: BuildContext, plugins: List<Plugin>) -> Child,
|
||||
): TestParentNode<Child> {
|
||||
return TestParentNode(Child::class, childNodeFactory)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user