From 0165ce8f7ddb640b45303e300207e10feab33fe5 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Thu, 15 Aug 2024 11:31:34 -0700 Subject: [PATCH] Test to demonstrate `rememberAnsweringNavigator` result handling (#1582) Adding this as an example for testing result handing for https://github.com/slackhq/circuit/discussions/1565 --- .../circuit/backstack/SaveableBackStack.kt | 13 ++- .../foundation/AnsweringNavigatorTest.kt | 98 +++++++++++++++++++ 2 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/AnsweringNavigatorTest.kt diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt index 5273184ec..66e2ddff2 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt @@ -112,12 +112,15 @@ internal constructor( } override fun pop(result: PopResult?): Record? { - val popped = entryList.removeFirstOrNull() - if (result != null) { - // Send the pending result to our new top record, but only if it's expecting one - topRecord?.apply { if (expectingResult()) sendResult(result) } + // Run in a snapshot to ensure the sendResult doesn't get missed. + return Snapshot.withMutableSnapshot { + val popped = entryList.removeFirstOrNull() + if (result != null) { + // Send the pending result to our new top record, but only if it's expecting one + topRecord?.apply { if (expectingResult()) sendResult(result) } + } + popped } - return popped } override fun saveState() { diff --git a/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/AnsweringNavigatorTest.kt b/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/AnsweringNavigatorTest.kt new file mode 100644 index 000000000..52d304154 --- /dev/null +++ b/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/AnsweringNavigatorTest.kt @@ -0,0 +1,98 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.foundation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import app.cash.turbine.Turbine +import com.slack.circuit.backstack.SaveableBackStack +import com.slack.circuit.backstack.rememberSaveableBackStack +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuit.runtime.screen.PopResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.runner.RunWith + +/** + * Test verifying that a [PopResult] is returned to a [rememberAnsweringNavigator] across a + * navigation event. + */ +@RunWith(ComposeUiTestRunner::class) +class AnsweringNavigatorTest { + + @get:Rule val composeTestRule = createComposeRule() + + private val state1 = Turbine() + private val state2 = Turbine() + + private val circuit = + Circuit.Builder() + .addPresenter { _, navigator, _ -> AnsweringPresenter(navigator) } + .addPresenter { _, navigator, _ -> PopPresenter(navigator) } + .addUi { state, _ -> SideEffect { state1.add(state) } } + .addUi { state, _ -> SideEffect { state2.add(state) } } + .build() + + @Test + fun `verify pop result is returned to the answering navigator`() = runTest { + with(composeTestRule) { + val backStack = setCircuitContent(circuit) + assertEquals(listOf(TestScreen), backStack.screens) + // Go to next screen + state1.awaitItem().eventSink() + assertEquals(listOf(TestScreen2, TestScreen), backStack.screens) + waitForIdle() + // Pop back to the previous screen + state2.awaitItem().eventSink() + assertEquals(listOf(TestScreen), backStack.screens) + waitForIdle() + assertEquals(TestPopResult, state1.expectMostRecentItem().resultCount) + } + } +} + +private fun ComposeContentTestRule.setCircuitContent(circuit: Circuit): SaveableBackStack { + lateinit var backStack: SaveableBackStack + setContent { + CircuitCompositionLocals(circuit) { + val saveableBackStack = rememberSaveableBackStack(TestScreen) { backStack = this } + val navigator = rememberCircuitNavigator(backStack = backStack, onRootPop = {}) + NavigableCircuitContent(navigator = navigator, backStack = saveableBackStack) + } + } + return backStack +} + +private val SaveableBackStack.screens + get() = map { it.screen } + +private data class State(val resultCount: TestPopResult? = null, val eventSink: () -> Unit) : + CircuitUiState + +private class AnsweringPresenter(private val navigator: Navigator) : Presenter { + + @Composable + override fun present(): State { + var result by remember { mutableStateOf(null) } + val answeringNavigator = rememberAnsweringNavigator(navigator) { result = it } + return State(result) { answeringNavigator.goTo(TestScreen2) } + } +} + +private class PopPresenter(private val navigator: Navigator) : Presenter { + + @Composable + override fun present(): State { + return State { navigator.pop(TestPopResult) } + } +}