Skip to content

Instantly share code, notes, and snippets.

@anaisbetts
Created April 11, 2024 17:41
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save anaisbetts/03340c6a15ce6032c66a062526fc724e to your computer and use it in GitHub Desktop.
Save anaisbetts/03340c6a15ce6032c66a062526fc724e to your computer and use it in GitHub Desktop.
Actions in Compose
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Mutex
abstract class ActionRunner<T> {
var result by mutableStateOf<Result<T>?>(null)
private set
var isRunning by mutableStateOf(false)
private set
private val runRequests = Channel<Unit>(1)
private val mutex = Mutex()
suspend fun run() {
if (!mutex.tryLock()) {
error("ActionRunner is already running")
}
try {
// replace this with a runRequests.receiveAsFlow().collectLatest {}
// to cancel the last request in flight and replace it with a new one
Log.i("ActionRunner", "running down requests!")
for (_dontcare in runRequests) {
// Push a dummy item on the queue to prevent any other runs from queuing
runRequests.send(Unit)
isRunning = true
try {
Log.i("ActionRunner", "running action!")
result = runCatching { onAction() }.also { result ->
// rethrow CancellationException to break the loop,
// but only if the runner's context is no longer active.
if (!currentCoroutineContext().isActive) {
val ex = result.exceptionOrNull()
if (ex is CancellationException) throw ex
// Throw our own if the action returned normally but the runner is cancelled
throw CancellationException("ActionRunner.run was cancelled", cause = ex)
}
Log.i("ActionRunner", "finishing action!")
}
} finally {
isRunning = false
runRequests.tryReceive()
}
}
} finally {
Log.i("ActionRunner", "finishing running down requests!")
mutex.unlock()
}
}
fun tryRun() {
Log.i("ActionRunner", "trying to run!")
runRequests.trySend(Unit) // ignore failed send
}
protected abstract suspend fun onAction(): T
}
inline fun <T> ActionRunner(
crossinline action: suspend () -> T
): ActionRunner<T> = object : ActionRunner<T>() {
override suspend fun onAction(): T = action()
}
@Composable
fun <T> rememberAction(key: Any?, action: suspend () -> T): ActionRunner<T> {
val runner = remember(key) { ActionRunner(action) }
LaunchedEffect(runner) {
runner.run()
}
return runner
}
import android.util.Log
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import junit.framework.Assert.assertEquals
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
@RunWith(JUnit4::class)
class ComposeRunnerTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun theMostBasicOfBasicTests() {
runBlocking {
val flow = MutableSharedFlow<Boolean>()
// Create the Jetpack Compose view
val myButton = @Composable {
Log.i("ActionRunner", "Composing our test!")
val onClickAction = rememberAction("") {
flow.take(1).collect()
return@rememberAction true
}
Log.i("ActionRunner", "running! ${onClickAction.isRunning}")
TextButton(onClick = onClickAction::tryRun, modifier = Modifier.testTag("button")) {
if (onClickAction.isRunning) {
Text("Loading...")
} else if (onClickAction.result?.isFailure == true) {
Text("Didn't work: ${onClickAction.result!!.exceptionOrNull()!!.message}")
} else if (onClickAction.result == null) {
Text("Click me")
} else {
Text("We did it!")
}
}
}
// Set the view as the content of the Activity
composeTestRule.setContent {
myButton()
}
// Find the Button view and perform a click action
composeTestRule.onNodeWithTag("button").performClick()
composeTestRule.awaitIdle()
// We haven't signaled the flow, so we should be pending
composeTestRule.onNodeWithText("Loading...").assertExists()
flow.emit(true)
// Now we signaled, so we find the result
composeTestRule.onNodeWithText("We did it!").assertExists()
}
}
@Test
fun invokingTheTryRunMethodMultipleTimesIsIgnored() {
var callCount = 0
runBlocking {
val flow = MutableSharedFlow<Boolean>()
// Create the Jetpack Compose view
val myButton = @Composable {
Log.i("ActionRunner", "Composing our test!")
val onClickAction = rememberAction("") {
callCount++
flow.take(1).collect()
return@rememberAction true
}
Log.i("ActionRunner", "running! ${onClickAction.isRunning}")
TextButton(onClick = onClickAction::tryRun, modifier = Modifier.testTag("button")) {
if (onClickAction.isRunning) {
Text("Loading...")
} else if (onClickAction.result?.isFailure == true) {
Text("Didn't work: ${onClickAction.result!!.exceptionOrNull()!!.message}")
} else if (onClickAction.result == null) {
Text("Click me")
} else {
Text("We did it!")
}
}
}
// Set the view as the content of the Activity
composeTestRule.setContent {
myButton()
}
assertEquals(0, callCount)
// Find the Button view and perform a click action
composeTestRule.onNodeWithTag("button").performClick()
composeTestRule.awaitIdle()
// We haven't signaled the flow, so we should be pending
composeTestRule.onNodeWithText("Loading...").assertExists()
assertEquals(1, callCount)
// Click it again, but it should be ignored!
composeTestRule.onNodeWithTag("button").performClick()
composeTestRule.awaitIdle()
assertEquals(1, callCount)
// Finally complete our result
flow.emit(true)
composeTestRule.awaitIdle()
assertEquals(1, callCount)
// One more time, we click the button, but because we don't have a
// pending action, it works
composeTestRule.onNodeWithTag("button").performClick()
composeTestRule.awaitIdle()
assertEquals(2, callCount)
}
}
@Test
fun throwingShouldBeCaptured() {
runBlocking {
val flow = MutableSharedFlow<Boolean>()
// Create the Jetpack Compose view
val errMsg = "Aieeeee!"
val myButton = @Composable {
Log.i("ActionRunner", "Composing our test!")
val onClickAction = rememberAction("") {
flow.take(1).collect()
error(errMsg)
}
Log.i("ActionRunner", "running! ${onClickAction.isRunning}")
TextButton(onClick = onClickAction::tryRun, modifier = Modifier.testTag("button")) {
if (onClickAction.isRunning) {
Text("Loading...")
} else if (onClickAction.result?.isFailure == true) {
Text("Didn't work: ${onClickAction.result!!.exceptionOrNull()!!.message}")
} else if (onClickAction.result == null) {
Text("Click me")
} else {
Text("We did it!")
}
}
}
// Set the view as the content of the Activity
composeTestRule.setContent {
myButton()
}
// Find the Button view and perform a click action
composeTestRule.onNodeWithTag("button").performClick()
composeTestRule.awaitIdle()
// We haven't signaled the flow, so we should be pending
composeTestRule.onNodeWithText("Loading...").assertExists()
flow.emit(true)
// Now we signaled, so we find the failed result
composeTestRule.onNodeWithText("We did it!").assertDoesNotExist()
composeTestRule.onNodeWithText("Didn't work: $errMsg").assertExists()
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment