From 0d4ee307e85d1f721b48618bf62d7e0b3e3270bd Mon Sep 17 00:00:00 2001 From: Sergej Shafarenka Date: Mon, 22 Jul 2024 14:52:52 +0200 Subject: [PATCH] BackNavigation + Tests --- .github/workflows/test.yml | 49 +++++++ README.md | 7 +- componental-compose/build.gradle.kts | 11 ++ componental/build.gradle.kts | 19 ++- .../halfbit/componental/ComponentContext.kt | 11 +- .../componental/back/BackNavigation.kt | 23 ++++ .../componental/back/BackNavigationOwner.kt | 34 +++++ .../componental/back/OnNavigateBack.kt | 6 + .../de/halfbit/componental/router/Router.kt | 1 + .../componental/router/slot/SlotRouter.kt | 11 +- .../componental/router/stack/StackRouter.kt | 22 ++-- .../componental/lifecycle/LifecycleTest.kt | 80 ++++++++++++ .../componental/router/slot/SlotTest.kt | 53 ++++++++ .../router/slot/createSlotRouter.kt | 68 ++++++++++ .../componental/router/stack/StackTest.kt | 120 ++++++++++++++++++ .../router/stack/createStackRouter.kt | 67 ++++++++++ .../componental/testing/collectIntoChannel.kt | 21 +++ .../componental/testing/runExitingTest.kt | 24 ++++ .../main/kotlin/root.publication.gradle.kts | 2 +- gradle/libs.versions.toml | 1 + 20 files changed, 608 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 componental/src/commonMain/kotlin/de/halfbit/componental/back/BackNavigation.kt create mode 100644 componental/src/commonMain/kotlin/de/halfbit/componental/back/BackNavigationOwner.kt create mode 100644 componental/src/commonMain/kotlin/de/halfbit/componental/back/OnNavigateBack.kt create mode 100644 componental/src/commonTest/kotlin/de/halfbit/componental/lifecycle/LifecycleTest.kt create mode 100644 componental/src/commonTest/kotlin/de/halfbit/componental/router/slot/SlotTest.kt create mode 100644 componental/src/commonTest/kotlin/de/halfbit/componental/router/slot/createSlotRouter.kt create mode 100644 componental/src/commonTest/kotlin/de/halfbit/componental/router/stack/StackTest.kt create mode 100644 componental/src/commonTest/kotlin/de/halfbit/componental/router/stack/createStackRouter.kt create mode 100644 componental/src/commonTest/kotlin/de/halfbit/componental/testing/collectIntoChannel.kt create mode 100644 componental/src/commonTest/kotlin/de/halfbit/componental/testing/runExitingTest.kt diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0505b94 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,49 @@ +name: Check + +permissions: + checks: write + +on: + pull_request: + paths-ignore: + - 'documentation/**' + - '*.md' + push: + branches: + - master + paths-ignore: + - 'documentation/**' + - '*.md' + +jobs: + test: + name: Run tests + + strategy: + matrix: + include: + - os: ubuntu-latest + task: testDebugUnit # jvm + android + - os: macos-latest + task: iosSimulatorArm64 + + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'adopt' + java-version: '17' + + - name: Run ${{ matrix.task }} test + run: ./gradlew ${{ matrix.task }}Test --stacktrace + + - name: Publish ${{ matrix.task }} report + uses: mikepenz/action-junit-report@v4 + if: success() || failure() + with: + check_name: ${{ matrix.task }} + report_paths: "componental/build/test-results/${{ matrix.task }}Test/TEST-*.xml" \ No newline at end of file diff --git a/README.md b/README.md index 56b2352..2bd2313 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,14 @@ dependencies { # Publishing -1. Bump version in `root.publication.gradle.kts` of the root project +1. Bump version in `root.publication.gradle.kts` 2. `./gradlew clean build publishAllPublicationsToCentralRepository` +## Local maven + +1. Set `X.X-SNAPSHOT` version in `root.publication.gradle.kts` +2. `./gradlew clean build publishToMavenLocal` + # Release Notes * 0.2 Module `componental` is exposed as API from `componental.compose` diff --git a/componental-compose/build.gradle.kts b/componental-compose/build.gradle.kts index cb1756a..a945b06 100644 --- a/componental-compose/build.gradle.kts +++ b/componental-compose/build.gradle.kts @@ -47,3 +47,14 @@ android { minSdk = libs.versions.android.sdk.min.get().toInt() } } + +// https://youtrack.jetbrains.com/issue/KT-61313 +tasks.withType().configureEach { + val publicationName = name.removePrefix("sign").removeSuffix("Publication") + tasks.findByName("linkDebugTest$publicationName")?.let { + mustRunAfter(it) + } + tasks.findByName("compileTestKotlin$publicationName")?.let { + mustRunAfter(it) + } +} diff --git a/componental/build.gradle.kts b/componental/build.gradle.kts index beb9758..0d560ee 100644 --- a/componental/build.gradle.kts +++ b/componental/build.gradle.kts @@ -12,7 +12,12 @@ plugins { kotlin { explicitApi() - jvm() + jvm { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) + } + } androidTarget { publishLibraryVariants("release") @OptIn(ExperimentalKotlinGradlePluginApi::class) @@ -36,6 +41,7 @@ kotlin { } commonTest.dependencies { implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) } } } @@ -47,3 +53,14 @@ android { minSdk = libs.versions.android.sdk.min.get().toInt() } } + +// https://youtrack.jetbrains.com/issue/KT-61313 +tasks.withType().configureEach { + val publicationName = name.removePrefix("sign").removeSuffix("Publication") + tasks.findByName("linkDebugTest$publicationName")?.let { + mustRunAfter(it) + } + tasks.findByName("compileTestKotlin$publicationName")?.let { + mustRunAfter(it) + } +} diff --git a/componental/src/commonMain/kotlin/de/halfbit/componental/ComponentContext.kt b/componental/src/commonMain/kotlin/de/halfbit/componental/ComponentContext.kt index c840a8a..257ccf6 100644 --- a/componental/src/commonMain/kotlin/de/halfbit/componental/ComponentContext.kt +++ b/componental/src/commonMain/kotlin/de/halfbit/componental/ComponentContext.kt @@ -1,5 +1,7 @@ +/** Copyright 2024 Halfbit GmbH, Sergej Shafarenka */ package de.halfbit.componental +import de.halfbit.componental.back.BackNavigationOwner import de.halfbit.componental.coroutines.CoroutineScopeOwner import de.halfbit.componental.lifecycle.Lifecycle import de.halfbit.componental.lifecycle.LifecycleOwner @@ -11,7 +13,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -public interface ComponentContext : LifecycleOwner, CoroutineScopeOwner, RestoratorOwner { +public interface ComponentContext : LifecycleOwner, CoroutineScopeOwner, RestoratorOwner, BackNavigationOwner { public companion object { public fun create( @@ -47,9 +49,12 @@ private class DefaultComponentContext( override val lifecycle: Lifecycle, override val coroutineScope: CoroutineScope, override val restorator: Restorator, -) : ComponentContext +) : ComponentContext, + BackNavigationOwner by BackNavigationOwner.create(lifecycle) -internal inline fun ComponentContext.doOnDestroy(crossinline callback: () -> Unit): ComponentContext { +internal inline fun ComponentContext.doOnDestroy( + crossinline callback: () -> Unit +): ComponentContext { lifecycle.subscribe( object : Lifecycle.Subscriber.Callbacks { override fun onDestroy() { diff --git a/componental/src/commonMain/kotlin/de/halfbit/componental/back/BackNavigation.kt b/componental/src/commonMain/kotlin/de/halfbit/componental/back/BackNavigation.kt new file mode 100644 index 0000000..18d206b --- /dev/null +++ b/componental/src/commonMain/kotlin/de/halfbit/componental/back/BackNavigation.kt @@ -0,0 +1,23 @@ +/** Copyright 2024 Halfbit GmbH, Sergej Shafarenka */ +package de.halfbit.componental.back + +public interface BackNavigation { + public fun register(onNavigateBack: OnNavigateBack) + + public companion object { + private val callbacks: MutableList = mutableListOf() + + internal fun register(callback: OnNavigateBack) { + callbacks += callback + } + + internal fun unregister(callback: OnNavigateBack) { + callbacks -= callback + } + + /** To be called by the screen holding compose UI, e.g. by an activity on android. */ + public fun dispatchOnNavigateBack() { + callbacks.toList().lastOrNull()?.onNavigateBack() + } + } +} diff --git a/componental/src/commonMain/kotlin/de/halfbit/componental/back/BackNavigationOwner.kt b/componental/src/commonMain/kotlin/de/halfbit/componental/back/BackNavigationOwner.kt new file mode 100644 index 0000000..7f676b7 --- /dev/null +++ b/componental/src/commonMain/kotlin/de/halfbit/componental/back/BackNavigationOwner.kt @@ -0,0 +1,34 @@ +/** Copyright 2024 Halfbit GmbH, Sergej Shafarenka */ +package de.halfbit.componental.back + +import de.halfbit.componental.lifecycle.Lifecycle + +public interface BackNavigationOwner { + public val backNavigation : BackNavigation + + public companion object { + public fun create(lifecycle: Lifecycle): BackNavigationOwner = + object : BackNavigationOwner { + override val backNavigation: BackNavigation = + object : BackNavigation { + override fun register(onNavigateBack: OnNavigateBack) { + lifecycle.subscribe( + object : Lifecycle.Subscriber.Callbacks { + override fun onResume() { + BackNavigation.register(onNavigateBack) + } + + override fun onPause() { + BackNavigation.unregister(onNavigateBack) + } + } + ) + } + } + } + } +} + +public inline fun BackNavigationOwner.onNavigateBack(onNavigateBack: OnNavigateBack) { + backNavigation.register(onNavigateBack) +} \ No newline at end of file diff --git a/componental/src/commonMain/kotlin/de/halfbit/componental/back/OnNavigateBack.kt b/componental/src/commonMain/kotlin/de/halfbit/componental/back/OnNavigateBack.kt new file mode 100644 index 0000000..e1c0f00 --- /dev/null +++ b/componental/src/commonMain/kotlin/de/halfbit/componental/back/OnNavigateBack.kt @@ -0,0 +1,6 @@ +/** Copyright 2024 Halfbit GmbH, Sergej Shafarenka */ +package de.halfbit.componental.back + +public fun interface OnNavigateBack { + public fun onNavigateBack() +} diff --git a/componental/src/commonMain/kotlin/de/halfbit/componental/router/Router.kt b/componental/src/commonMain/kotlin/de/halfbit/componental/router/Router.kt index 68db456..3842a55 100644 --- a/componental/src/commonMain/kotlin/de/halfbit/componental/router/Router.kt +++ b/componental/src/commonMain/kotlin/de/halfbit/componental/router/Router.kt @@ -1,3 +1,4 @@ +/** Copyright 2024 Halfbit GmbH, Sergej Shafarenka */ package de.halfbit.componental.router import kotlinx.coroutines.flow.Flow diff --git a/componental/src/commonMain/kotlin/de/halfbit/componental/router/slot/SlotRouter.kt b/componental/src/commonMain/kotlin/de/halfbit/componental/router/slot/SlotRouter.kt index 5155f05..47b96ef 100644 --- a/componental/src/commonMain/kotlin/de/halfbit/componental/router/slot/SlotRouter.kt +++ b/componental/src/commonMain/kotlin/de/halfbit/componental/router/slot/SlotRouter.kt @@ -1,3 +1,4 @@ +/** Copyright 2024 Halfbit GmbH, Sergej Shafarenka */ package de.halfbit.componental.router.slot import de.halfbit.componental.ComponentContext @@ -58,7 +59,7 @@ public fun ComponentContext.childSlot( val context = createChildContext( childLifecycle = childLifecycle, childCoroutineScope = coroutineScope.createChildCoroutineScope(tag), - restorator = Restorator(restoredChildState) + restorator = Restorator(restoredChildState), ) val node = RouteNode(id, childFactory(id, context)) return RuntimeRouteNode( @@ -71,7 +72,7 @@ public fun ComponentContext.childSlot( } } - fun Id?.asSlot(): Slot { + fun Id?.asRuntimeSlot(): Slot { val oldNode = runtimeNode val newNode = when (this) { null -> { @@ -108,14 +109,14 @@ public fun ComponentContext.childSlot( } return flow { - emit(activeId.asSlot()) + emit(activeId.asRuntimeSlot()) router.events.collect { event -> activeId = event.transform(activeId) - emit(activeId.asSlot()) + emit(activeId.asRuntimeSlot()) } }.stateIn( coroutineScope, SharingStarted.Eagerly, - initial.asSlot(), + initial.asRuntimeSlot(), ) } diff --git a/componental/src/commonMain/kotlin/de/halfbit/componental/router/stack/StackRouter.kt b/componental/src/commonMain/kotlin/de/halfbit/componental/router/stack/StackRouter.kt index 23a6dce..64c3f13 100644 --- a/componental/src/commonMain/kotlin/de/halfbit/componental/router/stack/StackRouter.kt +++ b/componental/src/commonMain/kotlin/de/halfbit/componental/router/stack/StackRouter.kt @@ -1,3 +1,4 @@ +/** Copyright 2024 Halfbit GmbH, Sergej Shafarenka */ package de.halfbit.componental.router.stack import de.halfbit.componental.ComponentContext @@ -32,7 +33,7 @@ public class StackRouter : Router>() { public fun StackRouter.push(id: I) { route( - event = Event { keys -> keys + id } + event = Event { ids -> ids + id } ) } @@ -56,7 +57,6 @@ public fun ComponentContext.childStack( childFactory: (id: Id, context: ComponentContext) -> Child, ): StateFlow> { val runtimeNodes = mutableMapOf>() - val restoredRoute: Map>? = restorator.restoreRoute()?.let { ProtoBuf.decodeFromByteArray( @@ -71,7 +71,7 @@ public fun ComponentContext.childStack( val context = createChildContext( childLifecycle = childLifecycle, childCoroutineScope = coroutineScope.createChildCoroutineScope(tag), - restorator = Restorator(restoredChildState) + restorator = Restorator(restoredChildState), ) val node = RouteNode(id, childFactory(id, context)) return RuntimeRouteNode( @@ -84,10 +84,10 @@ public fun ComponentContext.childStack( } } - fun Collection.asStack(): Stack { + fun Collection.asRuntimeStack(): Stack { - val stackNodes = map { key -> - runtimeNodes[key] ?: createRuntimeRouteNode(key) + val stackNodes = map { id -> + runtimeNodes[id] ?: createRuntimeRouteNode(id) } stackNodes.reversed().forEachIndexed { index, node -> @@ -118,22 +118,22 @@ public fun ComponentContext.childStack( } return flow { - emit(ids.asStack()) + emit(ids.asRuntimeStack()) router.events.collect { event -> ids = event.transform(ids) - emit(ids.asStack()) + emit(ids.asRuntimeStack()) } }.stateIn( coroutineScope, SharingStarted.Eagerly, - initial.asStack(), + initial.asRuntimeStack(), ) } private fun List>.toStack(): Stack { - check(isNotEmpty()) { "List used as a stack have at least one entry" } + check(isNotEmpty()) { "List used as a stack must have at least one entry" } return Stack( active = last(), - inactive = if (size == 1) emptyList() else subList(1, lastIndex), + inactive = if (size == 1) emptyList() else subList(0, lastIndex), ) } diff --git a/componental/src/commonTest/kotlin/de/halfbit/componental/lifecycle/LifecycleTest.kt b/componental/src/commonTest/kotlin/de/halfbit/componental/lifecycle/LifecycleTest.kt new file mode 100644 index 0000000..5df6b16 --- /dev/null +++ b/componental/src/commonTest/kotlin/de/halfbit/componental/lifecycle/LifecycleTest.kt @@ -0,0 +1,80 @@ +/** Copyright 2024 Halfbit GmbH, Sergej Shafarenka */ +package de.halfbit.componental.lifecycle + +import kotlin.test.Test +import kotlin.test.assertEquals + +class LifecycleTest { + + @Test + fun initial_defaultState() { + val lifecycle = MutableLifecycle.create() + val actual = lifecycle.state + assertEquals(Lifecycle.State.Initial, actual) + } + + @Test + fun moveToResumed_immediateTransition() { + val lifecycle = MutableLifecycle.create() + lifecycle.moveToState(Lifecycle.State.Resumed) + + val actual = lifecycle.state + assertEquals(Lifecycle.State.Resumed, actual) + } + + @Test + fun moveToResumed_thenToDestroyed_immediateTransition() { + val lifecycle = MutableLifecycle.create() + lifecycle.moveToState(Lifecycle.State.Resumed) + lifecycle.moveToState(Lifecycle.State.Destroyed) + + val actual = lifecycle.state + assertEquals(Lifecycle.State.Destroyed, actual) + } + + @Test + fun moveToResumed_callbacks() { + // given + val lifecycle = MutableLifecycle.create() + val actual = mutableListOf() + lifecycle.subscribe( + object : Lifecycle.Subscriber.States { + override fun onState(state: Lifecycle.State) { + actual += state + } + } + ) + + // when + lifecycle.moveToState(Lifecycle.State.Resumed) + + // then + val expected = listOf(Lifecycle.State.Created, Lifecycle.State.Started, Lifecycle.State.Resumed) + assertEquals(expected, actual) + } + + @Test + fun moveToResumed_thenToDestroyed_callbacks() { + // given + val lifecycle = MutableLifecycle.create() + val actual = mutableListOf() + lifecycle.subscribe( + object : Lifecycle.Subscriber.States { + override fun onState(state: Lifecycle.State) { + actual += state + } + } + ) + + // when + lifecycle.moveToState(Lifecycle.State.Resumed) + lifecycle.moveToState(Lifecycle.State.Destroyed) + + // then + val expected = listOf( + Lifecycle.State.Created, Lifecycle.State.Started, Lifecycle.State.Resumed, + Lifecycle.State.Started, Lifecycle.State.Created, Lifecycle.State.Destroyed, + ) + assertEquals(expected, actual) + } +} \ No newline at end of file diff --git a/componental/src/commonTest/kotlin/de/halfbit/componental/router/slot/SlotTest.kt b/componental/src/commonTest/kotlin/de/halfbit/componental/router/slot/SlotTest.kt new file mode 100644 index 0000000..3da3757 --- /dev/null +++ b/componental/src/commonTest/kotlin/de/halfbit/componental/router/slot/SlotTest.kt @@ -0,0 +1,53 @@ +/** Copyright 2024 Halfbit GmbH, Sergej Shafarenka */ +package de.halfbit.componental.router.slot + +import de.halfbit.componental.testing.runExitingTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class SlotTest { + + @Test + fun push_setsNewActiveChild() = runExitingTest { + val (router, events) = createSlotRouter(slot = null) + val actual = events.receive() + val expected = Slot(active = null) + assertEquals(expected, actual) + + router.set(Id.Page1) + val actual2 = events.receive() + val expected2 = Slot(active = page1()) + assertEquals(expected2, actual2) + } + + @Test + fun push_replacesActiveChild() = runExitingTest { + val (router, events) = createSlotRouter(slot = Id.Page1) + val actual = events.receive() + val expected = Slot(active = page1()) + assertEquals(expected, actual) + + router.set(Id.Page2) + val actual2 = events.receive() + val expected2 = Slot(active = page2()) + assertEquals(expected2, actual2) + + router.set(Id.Page3) + val actual3 = events.receive() + val expected3 = Slot(active = page3()) + assertEquals(expected3, actual3) + } + + @Test + fun push_clearsActiveChild() = runExitingTest { + val (router, events) = createSlotRouter(slot = Id.Page1) + val actual = events.receive() + val expected = Slot(active = page1()) + assertEquals(expected, actual) + + router.set(null) + val actual2 = events.receive() + val expected2 = Slot(active = null) + assertEquals(expected2, actual2) + } +} \ No newline at end of file diff --git a/componental/src/commonTest/kotlin/de/halfbit/componental/router/slot/createSlotRouter.kt b/componental/src/commonTest/kotlin/de/halfbit/componental/router/slot/createSlotRouter.kt new file mode 100644 index 0000000..444912c --- /dev/null +++ b/componental/src/commonTest/kotlin/de/halfbit/componental/router/slot/createSlotRouter.kt @@ -0,0 +1,68 @@ +/** Copyright 2024 Halfbit GmbH, Sergej Shafarenka */ +package de.halfbit.componental.router.slot + +import de.halfbit.componental.ComponentContext +import de.halfbit.componental.lifecycle.Lifecycle +import de.halfbit.componental.lifecycle.MutableLifecycle +import de.halfbit.componental.restorator.Restorator +import de.halfbit.componental.router.RouteNode +import de.halfbit.componental.testing.collectIntoChannel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.test.TestScope +import kotlinx.serialization.Serializable + +fun TestScope.createSlotRouter( + slot: Id? = null, + lifecycleState: Lifecycle.State = Lifecycle.State.Resumed, +): Pair, Channel>> { + val router = SlotRouter() + val lifecycle = MutableLifecycle.create() + val context = ComponentContext.create( + lifecycle = lifecycle, + lifecycleCoroutineScope = this, + restorator = Restorator(null) + ) + val stackFlow = context.childSlot( + router = router, + initial = slot, + serializer = { Id.serializer() }, + childFactory = { id, _ -> + when (id) { + Id.Page1 -> Child.Page1 + Id.Page2 -> Child.Page2 + Id.Page3 -> Child.Page3 + Id.Page4 -> Child.Page4 + } + }, + ) + + lifecycle.moveToState(lifecycleState) + return router to stackFlow.collectIntoChannel(this) +} + +fun page1(): RouteNode = RouteNode(Id.Page1, Child.Page1) +fun page2(): RouteNode = RouteNode(Id.Page2, Child.Page2) +fun page3(): RouteNode = RouteNode(Id.Page3, Child.Page3) +fun page4(): RouteNode = RouteNode(Id.Page4, Child.Page4) + +sealed interface Child { + data object Page1 : Child + data object Page2 : Child + data object Page3 : Child + data object Page4 : Child +} + +@Serializable +sealed interface Id { + @Serializable + data object Page1 : Id + + @Serializable + data object Page2 : Id + + @Serializable + data object Page3 : Id + + @Serializable + data object Page4 : Id +} diff --git a/componental/src/commonTest/kotlin/de/halfbit/componental/router/stack/StackTest.kt b/componental/src/commonTest/kotlin/de/halfbit/componental/router/stack/StackTest.kt new file mode 100644 index 0000000..280aa9d --- /dev/null +++ b/componental/src/commonTest/kotlin/de/halfbit/componental/router/stack/StackTest.kt @@ -0,0 +1,120 @@ +/** Copyright 2024 Halfbit GmbH, Sergej Shafarenka */ +package de.halfbit.componental.router.stack + +import de.halfbit.componental.back.BackNavigation +import de.halfbit.componental.back.OnNavigateBack +import de.halfbit.componental.back.onNavigateBack +import de.halfbit.componental.testing.ignore +import de.halfbit.componental.testing.runExitingTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class StackTest { + + @Test + fun push_addsPagesToStack() = runExitingTest { + + val (router, _, events) = createStackRouter(stack = listOf(Id.Page1)) + val actual = events.receive() + val expected = Stack(active = page1(), inactive = emptyList()) + assertEquals(expected, actual) + + router.push(Id.Page2) + val actual2 = events.receive() + val expected2 = Stack(active = page2(), inactive = listOf(page1())) + assertEquals(expected2, actual2) + + router.push(Id.Page3) + val actual3 = events.receive() + val expected3 = Stack(active = page3(), inactive = listOf(page1(), page2())) + assertEquals(expected3, actual3) + + router.push(Id.Page4) + val actual4 = events.receive() + val expected4 = Stack(active = page4(), inactive = listOf(page1(), page2(), page3())) + assertEquals(expected4, actual4) + } + + @Test + fun pop_removesPagesFromStack() = runExitingTest { + + val (router, _, events) = createStackRouter(stack = listOf(Id.Page1, Id.Page2, Id.Page3, Id.Page4)) + val actual = events.receive() + val expected = Stack(active = page4(), inactive = listOf(page1(), page2(), page3())) + assertEquals(expected, actual) + + router.pop { } + val actual2 = events.receive() + val expected2 = Stack(active = page3(), inactive = listOf(page1(), page2())) + assertEquals(expected2, actual2) + + router.pop { } + val actual3 = events.receive() + val expected3 = Stack(active = page2(), inactive = listOf(page1())) + assertEquals(expected3, actual3) + + router.pop { } + val actual4 = events.receive() + val expected4 = Stack(active = page1(), inactive = emptyList()) + assertEquals(expected4, actual4) + } + + @Test + fun pop_callsOnLastItem() = runExitingTest { + + // given + val (router, _, events) = createStackRouter(stack = listOf(Id.Page1)) + events.ignore() + + // when + var lastItemPopped = false + router.pop { lastItemPopped = true } + + // then + assertTrue(lastItemPopped) + } + + @Test + fun backPressed_removesPageFromStack() = runExitingTest { + + val (router, context, events) = createStackRouter(stack = listOf(Id.Page1, Id.Page2)) + context.onNavigateBack { router.pop { } } + events.ignore() + + BackNavigation.dispatchOnNavigateBack() + + val actual = events.receive() + val expected = Stack(active = page1(), inactive = emptyList()) + assertEquals(expected, actual) + } + + @Test + fun backPressed_callsOnLastItem_whenEmptyStack() = runExitingTest { + + var lastItemCalled = false + val (router, context, events) = createStackRouter(stack = listOf(Id.Page1)) + context.onNavigateBack { router.pop { lastItemCalled = true } } + events.ignore() + + BackNavigation.dispatchOnNavigateBack() + assertTrue(lastItemCalled) + } + + @Test + fun backPressed_callsOnLastItem_afterEmptyingStack() = runExitingTest { + + var lastItemCalled = false + val (router, context, events) = createStackRouter(stack = listOf(Id.Page1, Id.Page2)) + context.onNavigateBack { router.pop { lastItemCalled = true } } + events.ignore() + + BackNavigation.dispatchOnNavigateBack() + events.ignore() + assertFalse(lastItemCalled) + + BackNavigation.dispatchOnNavigateBack() + assertTrue(lastItemCalled) + } +} diff --git a/componental/src/commonTest/kotlin/de/halfbit/componental/router/stack/createStackRouter.kt b/componental/src/commonTest/kotlin/de/halfbit/componental/router/stack/createStackRouter.kt new file mode 100644 index 0000000..6010e71 --- /dev/null +++ b/componental/src/commonTest/kotlin/de/halfbit/componental/router/stack/createStackRouter.kt @@ -0,0 +1,67 @@ +/** Copyright 2024 Halfbit GmbH, Sergej Shafarenka */ +package de.halfbit.componental.router.stack + +import de.halfbit.componental.ComponentContext +import de.halfbit.componental.lifecycle.Lifecycle +import de.halfbit.componental.lifecycle.MutableLifecycle +import de.halfbit.componental.restorator.Restorator +import de.halfbit.componental.router.RouteNode +import de.halfbit.componental.testing.collectIntoChannel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.test.TestScope +import kotlinx.serialization.Serializable + +fun TestScope.createStackRouter( + stack: List, + lifecycleState: Lifecycle.State = Lifecycle.State.Resumed, +): Triple, ComponentContext, Channel>> { + val router = StackRouter() + val lifecycle = MutableLifecycle.create() + val context = ComponentContext.create( + lifecycle = lifecycle, + lifecycleCoroutineScope = this, + restorator = Restorator(null) + ) + val stackFlow = context.childStack( + router = router, + initial = stack, + serializer = { Id.serializer() }, + childFactory = { id, _ -> + when (id) { + Id.Page1 -> Child.Page1 + Id.Page2 -> Child.Page2 + Id.Page3 -> Child.Page3 + Id.Page4 -> Child.Page4 + } + }, + ) + lifecycle.moveToState(lifecycleState) + return Triple(router, context, stackFlow.collectIntoChannel(this)) +} + +fun page1(): RouteNode = RouteNode(Id.Page1, Child.Page1) +fun page2(): RouteNode = RouteNode(Id.Page2, Child.Page2) +fun page3(): RouteNode = RouteNode(Id.Page3, Child.Page3) +fun page4(): RouteNode = RouteNode(Id.Page4, Child.Page4) + +sealed interface Child { + data object Page1 : Child + data object Page2 : Child + data object Page3 : Child + data object Page4 : Child +} + +@Serializable +sealed interface Id { + @Serializable + data object Page1 : Id + + @Serializable + data object Page2 : Id + + @Serializable + data object Page3 : Id + + @Serializable + data object Page4 : Id +} diff --git a/componental/src/commonTest/kotlin/de/halfbit/componental/testing/collectIntoChannel.kt b/componental/src/commonTest/kotlin/de/halfbit/componental/testing/collectIntoChannel.kt new file mode 100644 index 0000000..44d1991 --- /dev/null +++ b/componental/src/commonTest/kotlin/de/halfbit/componental/testing/collectIntoChannel.kt @@ -0,0 +1,21 @@ +/** Copyright 2024 Halfbit GmbH, Sergej Shafarenka */ +package de.halfbit.componental.testing + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +fun StateFlow.collectIntoChannel(scope: CoroutineScope): Channel { + val channel = Channel() + scope.launch { + collect { + channel.send(it) + } + } + return channel +} + +suspend fun Channel.ignore() { + receive() +} diff --git a/componental/src/commonTest/kotlin/de/halfbit/componental/testing/runExitingTest.kt b/componental/src/commonTest/kotlin/de/halfbit/componental/testing/runExitingTest.kt new file mode 100644 index 0000000..71e2f3e --- /dev/null +++ b/componental/src/commonTest/kotlin/de/halfbit/componental/testing/runExitingTest.kt @@ -0,0 +1,24 @@ +/** Copyright 2024 Halfbit GmbH, Sergej Shafarenka */ +package de.halfbit.componental.testing + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +fun runExitingTest(testBody: suspend TestScope.() -> Unit) { + val exitingException = CancellationException("exited properly") + try { + runTest(UnconfinedTestDispatcher()) { + testBody() + cancel(exitingException) + } + } catch (e: CancellationException) { + if (e != exitingException) { + throw e + } + } +} diff --git a/convention-plugins/src/main/kotlin/root.publication.gradle.kts b/convention-plugins/src/main/kotlin/root.publication.gradle.kts index aee68a3..a28fe0a 100644 --- a/convention-plugins/src/main/kotlin/root.publication.gradle.kts +++ b/convention-plugins/src/main/kotlin/root.publication.gradle.kts @@ -4,5 +4,5 @@ plugins { allprojects { group = "de.halfbit" - version = "0.2" + version = "0.3-SNAPSHOT" } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6c27d0b..a4b86f4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ androidx-savedstate = "1.2.1" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "kotlinx-atomicfu" } kotlinx-serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "kotlinx-serialization" } androidx-lifecycle = { module = "androidx.lifecycle:lifecycle-common", version.ref = "androidx-lifecycle" }