From a735521c1edf3e6ed943f86da1510b36e3d8cfb4 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Fri, 3 May 2024 02:35:46 +1200 Subject: [PATCH] Added tests for nested results in sibling containers, updated DefaultContainerExecutor to correctly support this as would be expected. --- .../container/DefaultContainerExecutor.kt | 105 ++++++++--- .../dev/enro/core/container/EmptyBehavior.kt | 2 + .../usecase/NavigationHandleExtras.kt | 13 +- .../application/SelectDestinationRobot.kt | 11 ++ .../results/ComposeNestedResultsRobot.kt | 121 +++++++++++++ .../results/ComposeNestedResultsTest.kt | 77 ++++++++ .../compose/common/TitledColumn.kt | 2 +- .../application/compose/common/TitledRow.kt | 38 ++++ .../compose/results/ComposeNestedResults.kt | 169 ++++++++++++++++++ .../src/main/res/values-night/themes.xml | 2 +- 10 files changed, 512 insertions(+), 28 deletions(-) create mode 100644 tests/application/src/androidTest/java/dev/enro/test/application/compose/results/ComposeNestedResultsRobot.kt create mode 100644 tests/application/src/androidTest/java/dev/enro/test/application/compose/results/ComposeNestedResultsTest.kt create mode 100644 tests/application/src/main/java/dev/enro/tests/application/compose/common/TitledRow.kt create mode 100644 tests/application/src/main/java/dev/enro/tests/application/compose/results/ComposeNestedResults.kt diff --git a/enro-core/src/main/java/dev/enro/core/container/DefaultContainerExecutor.kt b/enro-core/src/main/java/dev/enro/core/container/DefaultContainerExecutor.kt index 1260fff7..0f033be1 100644 --- a/enro-core/src/main/java/dev/enro/core/container/DefaultContainerExecutor.kt +++ b/enro-core/src/main/java/dev/enro/core/container/DefaultContainerExecutor.kt @@ -1,6 +1,7 @@ package dev.enro.core.container import androidx.activity.ComponentActivity +import dev.enro.compatability.Compatibility import dev.enro.core.AnyOpenInstruction import dev.enro.core.ExecutorArgs import dev.enro.core.NavigationContainerKey @@ -10,7 +11,6 @@ import dev.enro.core.NavigationExecutor import dev.enro.core.NavigationKey import dev.enro.core.activity import dev.enro.core.activity.ActivityNavigationContainer -import dev.enro.compatability.Compatibility import dev.enro.core.getNavigationHandle import dev.enro.core.navigationContext import dev.enro.core.parentContainer @@ -67,6 +67,7 @@ internal object DefaultContainerExecutor : NavigationExecutor?, instruction: AnyOpenInstruction, + alreadyVisitedContainerKeys: Set = emptySet() ): NavigationContainer? { if (fromContext == null) return null if (instruction.navigationDirection == NavigationDirection.ReplaceRoot) { @@ -77,29 +78,85 @@ internal object DefaultContainerExecutor : NavigationExecutor defaultFragmentContainer - useActivityContainer -> ActivityNavigationContainer(fromContext.activity.navigationContext) - else -> it - } + val visited = alreadyVisitedContainerKeys.toMutableSet() + val container = containerManager + .getActiveChildContainers(exclude = visited) + .onEach { visited.add(it.key.name) } + .firstOrNull { + it.isVisible && it.accept(instruction) && it != defaultFragmentContainer } + ?: containerManager.getChildContainers(exclude = visited) + .onEach { visited.add(it.key.name) } + .filter { it.isVisible } + .filterNot { it == defaultFragmentContainer } + .firstOrNull { it.accept(instruction) } + .let { + val useDefaultFragmentContainer = it == null && + fromContext.parentContext == null && + defaultFragmentContainer != null && + defaultFragmentContainer.accept(instruction) + + val useActivityContainer = it == null && + fromContext.parentContext == null && + instruction.navigationDirection != NavigationDirection.Push - return container ?: findContainerFor(fromContext.parentContext, instruction) + when { + useDefaultFragmentContainer -> defaultFragmentContainer + useActivityContainer -> ActivityNavigationContainer(fromContext.activity.navigationContext) + else -> it + } + } + + return container ?: findContainerFor( + fromContext = fromContext.parentContext, + instruction = instruction, + alreadyVisitedContainerKeys = visited, + ) + } +} + +/** + * Returns a list of active child containers down from a particular NavigationContainerManager, the results in the list + * should be in descending distance from the container manager that this was invoked on. This means that the first result will + * be the active container for this container manager, and the next result will be the active container for that container manager, + * and so on. This method also takes an "exclude" parameter, which will exclude any containers with the given keys from the results, + * including their children. + */ +private fun NavigationContainerManager.getActiveChildContainers( + exclude: Set, +): List { + var activeContainer = activeContainer + val result = mutableListOf() + while (activeContainer != null) { + if (exclude.contains(activeContainer.key.name)) { + break + } + result.add(activeContainer) + activeContainer = activeContainer.childContext?.containerManager?.activeContainer + } + return result +} + +/** + * Returns a list of all child containers down from a particular NavigationContainerManager, the results in the list + * should be in descending distance from the container manager that this was invoked on. This is a breadth first search, + * and doesn't take into account the active container. This method also takes an "exclude" parameter, which will exclude any + * results with the given keys from the results, including the children of containers which are excluded. + */ +private fun NavigationContainerManager.getChildContainers( + exclude: Set, +): List { + val toVisit = mutableListOf() + toVisit.addAll(containers) + + val result = mutableListOf() + while (toVisit.isNotEmpty()) { + val next = toVisit.removeAt(0) + if (exclude.contains(next.key.name)) { + continue + } + result.add(next) + toVisit.addAll(next.childContext?.containerManager?.containers.orEmpty()) } -} \ No newline at end of file + return result +} diff --git a/enro-core/src/main/java/dev/enro/core/container/EmptyBehavior.kt b/enro-core/src/main/java/dev/enro/core/container/EmptyBehavior.kt index 2ae32e7b..1371ab08 100644 --- a/enro-core/src/main/java/dev/enro/core/container/EmptyBehavior.kt +++ b/enro-core/src/main/java/dev/enro/core/container/EmptyBehavior.kt @@ -36,6 +36,8 @@ public sealed class EmptyBehavior { * "true", then the action is considered to have consumed the request to become empty, and the container * will not close the last navigation destination. When the action function returns "false", the default * behaviour will happen, and the container will become empty. + * + * @returns true to keep the destination in the container, false to allow the container to become empty */ public class Action( public val onEmpty: () -> Boolean diff --git a/enro-core/src/main/java/dev/enro/core/controller/usecase/NavigationHandleExtras.kt b/enro-core/src/main/java/dev/enro/core/controller/usecase/NavigationHandleExtras.kt index 101f583d..972bdd76 100644 --- a/enro-core/src/main/java/dev/enro/core/controller/usecase/NavigationHandleExtras.kt +++ b/enro-core/src/main/java/dev/enro/core/controller/usecase/NavigationHandleExtras.kt @@ -14,8 +14,7 @@ import java.io.Closeable * is recreated, they must be bound every time a NavigationHandle object is created. For example, in the `onOpened` * method of an EnroPlugin. * - * Extras which extend java.io.Closeable will automatically have their close method called when the NavigationHandle is - * destroyed. + * Extras which extend java.io.Closeable will automatically have their close method called when the NavigationHandle is destroyed. */ public class NavigationHandleExtras : Closeable { internal val extras: SnapshotStateMap = mutableStateMapOf() @@ -28,5 +27,15 @@ public class NavigationHandleExtras : Closeable { } } +/** + * Access the extras map on a NavigationHandle. + * + * NavigationHandle.extras can be used for storing additional information or state with a NavigationHandle. Extras are not + * recreated when a NavigationHandle is recreated, and must be bound every time a NavigationHandle object is created. + * + * Extras which extend java.io.Closeable will automatically have their close method called when the NavigationHandle is destroyed. + * + * @see [NavigationHandleExtras] + */ public val NavigationHandle.extras: MutableMap get() = dependencyScope.get().extras \ No newline at end of file diff --git a/tests/application/src/androidTest/java/dev/enro/test/application/SelectDestinationRobot.kt b/tests/application/src/androidTest/java/dev/enro/test/application/SelectDestinationRobot.kt index bf85a20c..f0d4132f 100644 --- a/tests/application/src/androidTest/java/dev/enro/test/application/SelectDestinationRobot.kt +++ b/tests/application/src/androidTest/java/dev/enro/test/application/SelectDestinationRobot.kt @@ -14,6 +14,7 @@ import dev.enro.test.application.compose.FindContextRobot import dev.enro.test.application.compose.LegacyBottomSheetsRobot import dev.enro.test.application.compose.SyntheticViewModelAccessRobot import dev.enro.test.application.compose.results.ComposeEmbeddedResultFlowRobot +import dev.enro.test.application.compose.results.ComposeNestedResultsRobot import dev.enro.test.application.fragment.UnboundBottomSheetRobot import dev.enro.tests.application.SelectDestination @@ -117,4 +118,14 @@ class SelectDestinationRobot( return ComposeEmbeddedResultFlowRobot(composeRule) } + + fun openComposeNestedResults(): ComposeNestedResultsRobot { + composeRule.onNode(hasText("Compose Nested Results")) + .performScrollTo() + .onSiblings() + .filterToOne(hasText("Push")) + .performClick() + + return ComposeNestedResultsRobot(composeRule) + } } \ No newline at end of file diff --git a/tests/application/src/androidTest/java/dev/enro/test/application/compose/results/ComposeNestedResultsRobot.kt b/tests/application/src/androidTest/java/dev/enro/test/application/compose/results/ComposeNestedResultsRobot.kt new file mode 100644 index 00000000..db481df2 --- /dev/null +++ b/tests/application/src/androidTest/java/dev/enro/test/application/compose/results/ComposeNestedResultsRobot.kt @@ -0,0 +1,121 @@ +package dev.enro.test.application.compose.results + +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.espresso.Espresso +import dev.enro.test.application.waitForNavigationHandle +import dev.enro.tests.application.compose.results.ComposeNestedResults + +class ComposeNestedResultsRobot( + private val composeRule: ComposeTestRule +) { + init { + composeRule.waitForNavigationHandle { + it.key is ComposeNestedResults + } + } + + fun getReceiver() : ReceiverRobot { + return ReceiverRobot(composeRule) + } + + class ReceiverRobot(private val composeRule: ComposeTestRule) { + init { + composeRule.waitForNavigationHandle { + it.key is ComposeNestedResults.Receiver + } + } + + fun assertCurrentResult(result: String) : ReceiverRobot { + composeRule.onNodeWithText("Current Result: $result") + .assertExists() + return this + } + + fun assertSenderContainerIsVisible() : ReceiverRobot { + composeRule.onNodeWithText("Nested Sender Container") + .assertExists() + return this + } + + fun assertSenderContainerNotVisible() : ReceiverRobot { + composeRule.onNodeWithText("Nested Sender Container") + .assertDoesNotExist() + return this + } + + fun openNestedSenderContainer(): ReceiverRobot { + composeRule.onNodeWithText("Open Nested Sender Container") + .performScrollTo() + .performClick() + + NestedSenderContainerRobot(composeRule) + return this + } + + fun openSender(): SenderRobot { + composeRule.onNodeWithText("Get Result") + .performScrollTo() + .performClick() + + return SenderRobot(composeRule) + } + + } + + class NestedSenderContainerRobot(private val composeRule: ComposeTestRule) { + init { + composeRule.waitForNavigationHandle { + it.key is ComposeNestedResults.NestedSenderContainer + } + } + } + + class SenderRobot(private val composeRule: ComposeTestRule) { + init { + composeRule.waitForNavigationHandle { + it.key is ComposeNestedResults.Sender + } + } + + fun assertIsInsideContainer(): SenderRobot { + composeRule.onNodeWithText("Nested Sender Container") + .assertExists() + return this + } + + fun assertIsNotInContainer(): SenderRobot { + composeRule.onNodeWithText("Nested Sender Container") + .assertDoesNotExist() + return this + } + + fun sendA(): ReceiverRobot { + composeRule.onNodeWithText("Send A") + .performClick() + + return ReceiverRobot(composeRule) + } + + fun sendB(): ReceiverRobot { + composeRule.onNodeWithText("Send B") + .performClick() + + return ReceiverRobot(composeRule) + } + + fun closeWithButton(): ReceiverRobot { + composeRule.onNodeWithText("Close") + .performClick() + + return ReceiverRobot(composeRule) + } + + fun closeWithBackPress(): ReceiverRobot { + Espresso.pressBack() + return ReceiverRobot(composeRule) + } + } +} \ No newline at end of file diff --git a/tests/application/src/androidTest/java/dev/enro/test/application/compose/results/ComposeNestedResultsTest.kt b/tests/application/src/androidTest/java/dev/enro/test/application/compose/results/ComposeNestedResultsTest.kt new file mode 100644 index 00000000..4275b6d9 --- /dev/null +++ b/tests/application/src/androidTest/java/dev/enro/test/application/compose/results/ComposeNestedResultsTest.kt @@ -0,0 +1,77 @@ +package dev.enro.test.application.compose.results + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import dev.enro.test.application.SelectDestinationRobot +import dev.enro.tests.application.TestActivity +import org.junit.Rule +import org.junit.Test + +class ComposeNestedResultsTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun test() { + SelectDestinationRobot(composeRule) + .openComposeNestedResults() + .getReceiver() + .assertCurrentResult("(None)") + + .openSender() + .assertIsNotInContainer() + .sendA() + .assertCurrentResult("A") + .assertSenderContainerNotVisible() + + .openSender() + .assertIsNotInContainer() + .closeWithButton() + .assertCurrentResult("Closed") + .assertSenderContainerNotVisible() + + .openSender() + .assertIsNotInContainer() + .sendB() + .assertCurrentResult("B") + .assertSenderContainerNotVisible() + + .openSender() + .assertIsNotInContainer() + .closeWithBackPress() + .assertCurrentResult("Closed") + .assertSenderContainerNotVisible() + + .openNestedSenderContainer() + .assertSenderContainerIsVisible() + .openSender() + .assertIsInsideContainer() + .sendB() + .assertCurrentResult("B") + .assertSenderContainerNotVisible() + + .openNestedSenderContainer() + .assertSenderContainerIsVisible() + .openSender() + .assertIsInsideContainer() + .closeWithButton() + .assertCurrentResult("Closed") + .assertSenderContainerNotVisible() + + .openNestedSenderContainer() + .assertSenderContainerIsVisible() + .openSender() + .assertIsInsideContainer() + .sendA() + .assertCurrentResult("A") + .assertSenderContainerNotVisible() + + .openNestedSenderContainer() + .assertSenderContainerIsVisible() + .openSender() + .assertIsInsideContainer() + .closeWithBackPress() + .assertCurrentResult("Closed") + .assertSenderContainerNotVisible() + + } +} \ No newline at end of file diff --git a/tests/application/src/main/java/dev/enro/tests/application/compose/common/TitledColumn.kt b/tests/application/src/main/java/dev/enro/tests/application/compose/common/TitledColumn.kt index ff71fc02..db829296 100644 --- a/tests/application/src/main/java/dev/enro/tests/application/compose/common/TitledColumn.kt +++ b/tests/application/src/main/java/dev/enro/tests/application/compose/common/TitledColumn.kt @@ -20,7 +20,7 @@ fun TitledColumn( content: @Composable ColumnScope.() -> Unit, ) { Column( - Modifier + modifier .fillMaxSize() .background(MaterialTheme.colors.background) .padding(16.dp) diff --git a/tests/application/src/main/java/dev/enro/tests/application/compose/common/TitledRow.kt b/tests/application/src/main/java/dev/enro/tests/application/compose/common/TitledRow.kt new file mode 100644 index 00000000..0b467a59 --- /dev/null +++ b/tests/application/src/main/java/dev/enro/tests/application/compose/common/TitledRow.kt @@ -0,0 +1,38 @@ +package dev.enro.tests.application.compose.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun TitledRow( + title: String, + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { + Column( + modifier + .fillMaxSize() + .background(MaterialTheme.colors.background) + .padding(16.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.h6 + ) + Spacer(modifier = Modifier.height(16.dp)) + Row { + content() + } + } +} \ No newline at end of file diff --git a/tests/application/src/main/java/dev/enro/tests/application/compose/results/ComposeNestedResults.kt b/tests/application/src/main/java/dev/enro/tests/application/compose/results/ComposeNestedResults.kt new file mode 100644 index 00000000..f56c464a --- /dev/null +++ b/tests/application/src/main/java/dev/enro/tests/application/compose/results/ComposeNestedResults.kt @@ -0,0 +1,169 @@ +package dev.enro.tests.application.compose.results + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import dev.enro.annotations.NavigationDestination +import dev.enro.core.NavigationKey +import dev.enro.core.closeWithResult +import dev.enro.core.compose.navigationHandle +import dev.enro.core.compose.registerForNavigationResult +import dev.enro.core.compose.rememberNavigationContainer +import dev.enro.core.container.EmptyBehavior +import dev.enro.core.container.accept +import dev.enro.core.container.acceptNone +import dev.enro.core.push +import dev.enro.core.requestClose +import dev.enro.tests.application.compose.common.TitledColumn +import dev.enro.tests.application.compose.common.TitledRow +import kotlinx.parcelize.Parcelize + +@Parcelize +object ComposeNestedResults : NavigationKey.SupportsPush { + @Parcelize + internal object Receiver : NavigationKey.SupportsPush + + @Parcelize + internal object NestedSenderContainer : NavigationKey.SupportsPush + + @Parcelize + internal object Sender : NavigationKey.SupportsPush.WithResult +} + +@NavigationDestination(ComposeNestedResults::class) +@Composable +fun ComposeNestedResults() { + val primary = rememberNavigationContainer( + root = ComposeNestedResults.Receiver, + emptyBehavior = EmptyBehavior.CloseParent, + filter = acceptNone(), + ) + val secondary = rememberNavigationContainer( + emptyBehavior = EmptyBehavior.Action { + primary.setActive() + false + }, + filter = accept { + key(ComposeNestedResults.NestedSenderContainer) + } + ) + val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + if (isLandscape) { + TitledRow(title = "Compose Nested Results") { + Box(modifier = Modifier + .weight(1f) + .fillMaxHeight()) { + primary.Render() + } + Box(modifier = Modifier + .weight(1f) + .fillMaxHeight()) { + secondary.Render() + } + } + } + else { + TitledColumn(title = "Compose Nested Results") { + Box(modifier = Modifier + .weight(1f) + .fillMaxWidth()) { + primary.Render() + } + Box(modifier = Modifier + .weight(1f) + .fillMaxWidth()) { + secondary.Render() + } + } + } +} + +@NavigationDestination(ComposeNestedResults.Receiver::class) +@Composable +fun ComposeNestedResultsReceiver() { + var result by remember { mutableStateOf("(None)") } + val navigation = navigationHandle() + val resultChannel = registerForNavigationResult( + onClosed = { + result = "Closed" + }, + onResult = { + result = it + } + ) + TitledColumn(title = "Receiver", modifier = Modifier.verticalScroll(rememberScrollState())) { + Text(text = "Current Result: $result") + val captionSize = MaterialTheme.typography.caption.fontSize * 0.8f + Text( + text = "You may need to scroll this container to see the buttons which perform actions." + + "\n\n" + + "If the secondary container has been opened, when using 'getResult', the result destination will be " + + "opened as a nested child of the secondary container. If this is the case, when the 'getResult' " + + "destination closes (whether it returns a result or not) it will cause the secondary container's " + + "destination to become empty, causing that container to trigger a close parent, which will cause " + + "that parent container to become empty, triggering the empty behavior defined in ComposeNestedResults; " + + "this means both the result sender destination and the secondary container destination will be closed, " + + "and this destination will become active again." + + "\n\n" + + "If the secondary container has not been opened, 'getResult' will push into the parent container of " + + "the ComposeNestedResults destination, and appear as a full screen destination.", + style = MaterialTheme.typography.caption.copy( + lineHeight = captionSize, + fontSize = captionSize, + ), + ) + Button(onClick = { navigation.push(ComposeNestedResults.NestedSenderContainer) }) { + Text(text = "Open Nested Sender Container") + } + Button(onClick = { resultChannel.push(ComposeNestedResults.Sender) }) { + Text(text = "Get Result") + } + } +} + +@NavigationDestination(ComposeNestedResults.NestedSenderContainer::class) +@Composable +fun ComposeNestedResultsNestedSenderContainer() { + val container = rememberNavigationContainer( + emptyBehavior = EmptyBehavior.CloseParent, + filter = accept { + key(ComposeNestedResults.Sender) + }, + ) + TitledColumn(title = "Nested Sender Container") { + Box(modifier = Modifier.fillMaxSize()) { + container.Render() + } + } +} + +@NavigationDestination(ComposeNestedResults.Sender::class) +@Composable +fun ComposeNestedResultsSender() { + val navigation = navigationHandle() + TitledColumn(title = "Sender") { + Button(onClick = { navigation.closeWithResult("A") }) { + Text(text = "Send A") + } + Button(onClick = { navigation.closeWithResult("B") }) { + Text(text = "Send B") + } + Button(onClick = { navigation.requestClose() } ) { + Text(text = "Close") + } + } +} diff --git a/tests/application/src/main/res/values-night/themes.xml b/tests/application/src/main/res/values-night/themes.xml index 93ec81f1..fbcb09ab 100644 --- a/tests/application/src/main/res/values-night/themes.xml +++ b/tests/application/src/main/res/values-night/themes.xml @@ -1,6 +1,6 @@ -