diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeHost.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeHost.kt
index 4da11616cf..09c04af806 100644
--- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeHost.kt
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeHost.kt
@@ -15,16 +15,184 @@
*/
package app.cash.redwood.treehouse
-/** Manages loading and hot-reloading a series of code sessions. */
-internal interface CodeHost {
- val stateStore: StateStore
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.job
+import kotlinx.coroutines.launch
- /** Only accessed on [TreehouseDispatchers.ui]. */
- val session: CodeSession?
+/**
+ * Manages loading and hot-reloading a series of code sessions.
+ *
+ * The code host has 4 states:
+ *
+ * * `Idle`
+ * * `Starting`: collect code updates, and wait for an `Zipline` to load.
+ * * `Running`: collect code updates, and a `Zipline` is running.
+ * * `Crashed`: collect code updates, but the most recent `Zipline` failed.
+ *
+ * Transitions between states always occur on the UI dispatcher. These functions initiate state
+ * transitions:
+ *
+ * * `start()` - transition to `Starting` unless it’s `Starting` or `Running`.
+ * * `stop()` - transition to `Idle` immediately
+ * * `restart()` - transition to `Starting` unless it’s already `Starting`.
+ *
+ * Other state transitions also occur:
+ *
+ * * From `Starting` to `Running` when a `Zipline` finishes loading.
+ * * From `Running` to `Crashed` when a `Zipline` fails.
+ * * From `Running` to `Running` when the `Zipline` is replaced by a hot-reload.
+ */
+internal abstract class CodeHost(
+ private val dispatchers: TreehouseDispatchers,
+ private val appScope: CoroutineScope,
+ private val frameClockFactory: FrameClock.Factory,
+ val stateStore: StateStore,
+) {
+ /** Contents that this app is currently responsible for. */
+ private val listeners = mutableListOf>()
+
+ private var state: State = State.Idle()
+
+ private val codeSessionListener = object : CodeSession.Listener {
+ override fun onUncaughtException(codeSession: CodeSession, exception: Throwable) {
+ }
+
+ override fun onCancel(codeSession: CodeSession) {
+ dispatchers.checkUi()
+
+ codeSession.removeListener(this)
+
+ // If a code session is canceled while we're still listening to it, it must have crashed.
+ val previous = state
+ if (previous is State.Running) {
+ state = State.Crashed(previous.codeUpdatesScope)
+ }
+ }
+ }
+
+ val codeSession: CodeSession?
+ get() = state.codeSession
+
+ /** Returns a flow that emits a new [CodeSession] each time we should load fresh code. */
+ abstract fun codeUpdatesFlow(): Flow>
+
+ fun start() {
+ dispatchers.checkUi()
+
+ val previous = state
+
+ if (previous is State.Starting || previous is State.Running) return // Nothing to do.
+
+ // Force a restart if we're crashed.
+ previous.codeUpdatesScope?.cancel()
+
+ val codeUpdatesScope = codeUpdatesScope()
+ state = State.Starting(codeUpdatesScope)
+ codeUpdatesScope.collectCodeUpdates()
+ }
+
+ /** This function may only be invoked on [TreehouseDispatchers.zipline]. */
+ fun stop() {
+ dispatchers.checkUi()
+
+ val previous = state
+ previous.codeUpdatesScope?.cancel()
+ previous.codeSession?.removeListener(codeSessionListener)
+ previous.codeSession?.cancel()
+
+ state = State.Idle()
+ }
+
+ fun restart() {
+ dispatchers.checkUi()
+
+ val previous = state
+ if (previous is State.Starting) return // Nothing to restart.
+
+ previous.codeUpdatesScope?.cancel()
+ previous.codeSession?.removeListener(codeSessionListener)
+ previous.codeSession?.cancel()
+
+ val codeUpdatesScope = codeUpdatesScope()
+ state = State.Starting(codeUpdatesScope)
+ codeUpdatesScope.collectCodeUpdates()
+ }
+
+ fun addListener(listener: Listener) {
+ dispatchers.checkUi()
+ listeners += listener
+ }
+
+ fun removeListener(listener: Listener) {
+ dispatchers.checkUi()
+ listeners -= listener
+ }
+
+ private fun codeUpdatesScope() =
+ CoroutineScope(SupervisorJob(appScope.coroutineContext.job))
+
+ private fun CoroutineScope.collectCodeUpdates() {
+ launch(dispatchers.zipline) {
+ codeUpdatesFlow().collect {
+ codeSessionLoaded(it)
+ }
+ }
+ }
+
+ private fun codeSessionLoaded(next: CodeSession) {
+ dispatchers.checkZipline()
+
+ next.scope.launch(dispatchers.ui) {
+ // Clean up the previous session.
+ val previous = state
+ previous.codeSession?.removeListener(codeSessionListener)
+ previous.codeSession?.cancel()
+
+ // If the codeUpdatesScope is null, we're stopped. Discard the newly-loaded code.
+ val codeUpdatesScope = state.codeUpdatesScope
+ if (codeUpdatesScope == null) {
+ next.cancel()
+ return@launch
+ }
+
+ // Boot up the new code.
+ state = State.Running(codeUpdatesScope, next)
+ next.addListener(codeSessionListener)
+ next.start()
+
+ for (listener in listeners) {
+ listener.codeSessionChanged(next)
+ }
+ }
+ }
+
+ private sealed class State {
+ /** Non-null if we're prepared for code updates and restarts. */
+ open val codeUpdatesScope: CoroutineScope?
+ get() = null
+
+ /** Non-null if we're running code. */
+ open val codeSession: CodeSession?
+ get() = null
- fun addListener(listener: Listener)
+ class Idle : State()
- fun removeListener(listener: Listener)
+ class Running(
+ override val codeUpdatesScope: CoroutineScope,
+ override val codeSession: CodeSession,
+ ) : State()
+
+ class Starting(
+ override val codeUpdatesScope: CoroutineScope,
+ ) : State()
+
+ class Crashed(
+ override val codeUpdatesScope: CoroutineScope,
+ ) : State()
+ }
interface Listener {
fun codeSessionChanged(next: CodeSession)
diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeSession.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeSession.kt
index 1334353558..e3c78d8b55 100644
--- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeSession.kt
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeSession.kt
@@ -22,13 +22,15 @@ import kotlinx.serialization.json.Json
/** The host state for a single code load. We get a new session each time we get new code. */
internal interface CodeSession {
+ val scope: CoroutineScope
+
val eventPublisher: EventPublisher
val appService: A
val json: Json
- fun start(sessionScope: CoroutineScope, frameClock: FrameClock)
+ fun start()
fun addListener(listener: Listener)
diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt
index 2c635db643..4fb644ca1d 100644
--- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt
@@ -24,10 +24,8 @@ import app.cash.zipline.loader.ZiplineHttpClient
import app.cash.zipline.loader.ZiplineLoader
import kotlin.native.ObjCName
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.job
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.flow.mapNotNull
import kotlinx.serialization.modules.EmptySerializersModule
import kotlinx.serialization.modules.SerializersModule
import okio.Closeable
@@ -36,8 +34,6 @@ import okio.Path
/**
* This class binds downloaded code to on-screen views.
- *
- * It updates the content when new code is available in [onCodeChanged].
*/
@ObjCName("TreehouseApp", exact = true)
public class TreehouseApp private constructor(
@@ -45,14 +41,27 @@ public class TreehouseApp private constructor(
private val appScope: CoroutineScope,
public val spec: Spec,
) {
- private val codeHost = ZiplineCodeHost()
-
public val dispatchers: TreehouseDispatchers = factory.dispatchers
- private var started = false
-
- /** Only accessed on [TreehouseDispatchers.zipline]. */
- private var closed = false
+ private val codeHost = object : CodeHost(
+ dispatchers = dispatchers,
+ appScope = appScope,
+ frameClockFactory = factory.frameClockFactory,
+ stateStore = factory.stateStore,
+ ) {
+ override fun codeUpdatesFlow(): Flow> {
+ return ziplineFlow().mapNotNull { loadResult ->
+ when (loadResult) {
+ is LoadResult.Failure -> {
+ null // EventListener already notified.
+ }
+ is LoadResult.Success -> {
+ createCodeSession(loadResult.zipline)
+ }
+ }
+ }
+ }
+ }
/**
* Returns the current zipline attached to this host, or null if Zipline hasn't loaded yet. The
@@ -62,7 +71,7 @@ public class TreehouseApp private constructor(
* instance may be replaced if new code is loaded.
*/
public val zipline: Zipline?
- get() = codeHost.session?.zipline
+ get() = (codeHost.codeSession as? ZiplineCodeSession)?.zipline
/**
* Create content for [source].
@@ -78,7 +87,6 @@ public class TreehouseApp private constructor(
return TreehouseAppContent(
codeHost = codeHost,
dispatchers = dispatchers,
- appScope = appScope,
codeListener = codeListener,
source = source,
)
@@ -89,25 +97,29 @@ public class TreehouseApp private constructor(
* this app.
*
* This function returns immediately if this app is already started.
+ *
+ * This function may only be invoked on [TreehouseDispatchers.ui].
*/
public fun start() {
- if (started) return
- started = true
+ codeHost.start()
+ }
- appScope.launch(dispatchers.zipline) {
- val ziplineFileFlow = ziplineFlow()
- ziplineFileFlow.collect {
- when (it) {
- is LoadResult.Success -> {
- val app = spec.create(it.zipline)
- onCodeChanged(it.zipline, app)
- }
- is LoadResult.Failure -> {
- // EventListener already notified.
- }
- }
- }
- }
+ /**
+ * Stop any currently-running code and stop receiving new code.
+ *
+ * This function may only be invoked on [TreehouseDispatchers.ui].
+ */
+ public fun stop() {
+ codeHost.stop()
+ }
+
+ /**
+ * Stop the currently-running application (if any) and start it again.
+ *
+ * This function may only be invoked on [TreehouseDispatchers.ui].
+ */
+ public fun restart() {
+ codeHost.restart()
}
/**
@@ -153,95 +165,21 @@ public class TreehouseApp private constructor(
}
}
- /**
- * Refresh the code. Even if no views are currently showing we refresh the code, so we're ready
- * when a view is added.
- *
- * This function may only be invoked on [TreehouseDispatchers.zipline].
- */
- private fun onCodeChanged(zipline: Zipline, appService: A) {
- dispatchers.checkZipline()
- check(!closed)
-
- codeHost.onCodeChanged(zipline, appService)
- }
-
- /** This function may only be invoked on [TreehouseDispatchers.zipline]. */
- public fun cancel() {
- dispatchers.checkZipline()
- closed = true
- appScope.launch(dispatchers.ui) {
- val session = codeHost.session ?: return@launch
- session.removeListener(codeHost)
- session.cancel()
- codeHost.session = null
- }
- }
-
- private inner class ZiplineCodeHost : CodeHost, CodeSession.Listener {
- /**
- * Contents that this app is currently responsible for.
- *
- * Only accessed on [TreehouseDispatchers.ui].
- */
- private val listeners = mutableListOf>()
-
- override val stateStore: StateStore = factory.stateStore
-
- override var session: ZiplineCodeSession? = null
-
- override fun addListener(listener: CodeHost.Listener) {
- dispatchers.checkUi()
- listeners += listener
- }
-
- override fun removeListener(listener: CodeHost.Listener) {
- dispatchers.checkUi()
- listeners -= listener
- }
-
- override fun onUncaughtException(codeSession: CodeSession, exception: Throwable) {
- }
-
- override fun onCancel(codeSession: CodeSession) {
- check(codeSession == this.session)
- this.session = null
- }
-
- fun onCodeChanged(zipline: Zipline, appService: A) {
- // Extract the RealEventPublisher() created in ziplineFlow().
- val eventListener = zipline.eventListener as RealEventPublisher.ZiplineEventListener
- val eventPublisher = eventListener.eventPublisher
-
- val next = ZiplineCodeSession(
- dispatchers = dispatchers,
- appScope = appScope,
- eventPublisher = eventPublisher,
- appService = appService,
- zipline = zipline,
- )
-
- val sessionScope = CoroutineScope(
- SupervisorJob(appScope.coroutineContext.job) + next.coroutineExceptionHandler,
- )
+ private fun createCodeSession(zipline: Zipline): ZiplineCodeSession {
+ val appService = spec.create(zipline)
- sessionScope.launch(dispatchers.ui) {
- val previous = session
- previous?.removeListener(this@ZiplineCodeHost)
- previous?.cancel()
+ // Extract the RealEventPublisher() created in ziplineFlow().
+ val eventListener = zipline.eventListener as RealEventPublisher.ZiplineEventListener
+ val eventPublisher = eventListener.eventPublisher
- session = next
- next.addListener(this@ZiplineCodeHost)
- next.start(
- sessionScope = sessionScope,
- frameClock = factory.frameClockFactory.create(sessionScope, dispatchers),
- )
-
- for (listener in listeners) {
- listener.codeSessionChanged(next)
- }
- }
- }
+ return ZiplineCodeSession(
+ dispatchers = dispatchers,
+ eventPublisher = eventPublisher,
+ frameClockFactory = factory.frameClockFactory,
+ appService = appService,
+ zipline = zipline,
+ appScope = appScope,
+ )
}
/**
diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt
index 399fcacf02..99daa991fd 100644
--- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt
@@ -59,7 +59,9 @@ private sealed interface ViewState {
}
private sealed interface CodeState {
- class Idle : CodeState
+ class Idle(
+ val isInitialLaunch: Boolean,
+ ) : CodeState
class Running(
val viewContentCodeBinding: ViewContentCodeBinding,
@@ -70,11 +72,12 @@ private sealed interface CodeState {
internal class TreehouseAppContent(
private val codeHost: CodeHost,
private val dispatchers: TreehouseDispatchers,
- private val appScope: CoroutineScope,
private val codeListener: CodeListener,
private val source: TreehouseContentSource,
) : Content, CodeHost.Listener, CodeSession.Listener {
- private val stateFlow = MutableStateFlow>(State(ViewState.None, CodeState.Idle()))
+ private val stateFlow = MutableStateFlow>(
+ State(ViewState.None, CodeState.Idle(isInitialLaunch = true)),
+ )
override fun preload(
onBackPressedDispatcher: OnBackPressedDispatcher,
@@ -88,7 +91,7 @@ internal class TreehouseAppContent(
val nextViewState = ViewState.Preloading(onBackPressedDispatcher, uiConfiguration)
// Start the code if necessary.
- val codeSession = codeHost.session
+ val codeSession = codeHost.codeSession
val nextCodeState = when {
previousState.codeState is CodeState.Idle && codeSession != null -> {
CodeState.Running(
@@ -120,7 +123,7 @@ internal class TreehouseAppContent(
val nextViewState = ViewState.Bound(view)
// Start the code if necessary.
- val codeSession = codeHost.session
+ val codeSession = codeHost.codeSession
val nextCodeState = when {
previousState.codeState is CodeState.Idle && codeSession != null -> {
CodeState.Running(
@@ -169,7 +172,7 @@ internal class TreehouseAppContent(
if (previousViewState is ViewState.None) return // Idempotent.
val nextViewState = ViewState.None
- val nextCodeState = CodeState.Idle()
+ val nextCodeState = CodeState.Idle(isInitialLaunch = true)
// Cancel the code if necessary.
codeHost.removeListener(this)
@@ -204,7 +207,7 @@ internal class TreehouseAppContent(
val nextCodeState = CodeState.Running(
startViewCodeContentBinding(
codeSession = next,
- isInitialLaunch = previousCodeState is CodeState.Idle,
+ isInitialLaunch = (previousCodeState as? CodeState.Idle)?.isInitialLaunch == true,
onBackPressedDispatcher = onBackPressedDispatcher,
firstUiConfiguration = uiConfiguration,
),
@@ -225,11 +228,19 @@ internal class TreehouseAppContent(
stateFlow.value = State(viewState, nextCodeState)
}
+ override fun onUncaughtException(codeSession: CodeSession, exception: Throwable) {
+ codeSessionCanceled(exception = exception)
+ }
+
+ override fun onCancel(codeSession: CodeSession) {
+ codeSessionCanceled(exception = null)
+ }
+
/**
- * If the code crashes, show an error on the UI and cancel the UI binding. This sets the code
- * state back to idle.
+ * If the code crashes or is unloaded, show an error on the UI and cancel the UI binding. This
+ * sets the code state back to idle.
*/
- override fun onUncaughtException(codeSession: CodeSession, exception: Throwable) {
+ private fun codeSessionCanceled(exception: Throwable?) {
dispatchers.checkUi()
val previousState = stateFlow.value
@@ -239,24 +250,21 @@ internal class TreehouseAppContent(
// This listener should only fire if we're actively running code.
require(previousCodeState is CodeState.Running)
- // Cancel the UI binding to the crashed code.
+ // Cancel the UI binding to the canceled code.
val binding = previousCodeState.viewContentCodeBinding
binding.cancel()
binding.codeSession.removeListener(this)
- // If there's a UI, give it the error to display.
+ // If there's an error and a UI, show it.
val view = (viewState as? ViewState.Bound)?.view
- if (view != null) {
+ if (exception != null && view != null) {
codeListener.onUncaughtException(view, exception)
}
- val nextCodeState = CodeState.Idle()
+ val nextCodeState = CodeState.Idle(isInitialLaunch = false)
stateFlow.value = State(viewState, nextCodeState)
}
- override fun onCancel(codeSession: CodeSession) {
- }
-
/** This function may only be invoked on [TreehouseDispatchers.ui]. */
private fun startViewCodeContentBinding(
codeSession: CodeSession,
@@ -270,7 +278,6 @@ internal class TreehouseAppContent(
return ViewContentCodeBinding(
stateStore = codeHost.stateStore,
dispatchers = dispatchers,
- appScope = appScope,
eventPublisher = codeSession.eventPublisher,
contentSource = source,
codeListener = codeListener,
@@ -300,7 +307,6 @@ internal class TreehouseAppContent(
private class ViewContentCodeBinding(
val stateStore: StateStore,
val dispatchers: TreehouseDispatchers,
- val appScope: CoroutineScope,
val eventPublisher: EventPublisher,
val contentSource: TreehouseContentSource,
val codeListener: CodeListener,
@@ -313,7 +319,7 @@ private class ViewContentCodeBinding(
private val uiConfigurationFlow = SequentialStateFlow(firstUiConfiguration)
private val bindingScope = CoroutineScope(
- SupervisorJob(appScope.coroutineContext.job) + codeSession.coroutineExceptionHandler,
+ SupervisorJob(codeSession.scope.coroutineContext.job),
)
/** Only accessed on [TreehouseDispatchers.ui]. Null before [initView] and after [cancel]. */
@@ -486,10 +492,10 @@ private class ViewContentCodeBinding(
viewOrNull?.saveCallback = null
viewOrNull = null
bridgeOrNull = null
- appScope.launch(dispatchers.zipline) {
+ bindingScope.launch(dispatchers.zipline) {
treehouseUiOrNull = null
- bindingScope.cancel()
serviceScope.close()
+ bindingScope.cancel()
}
}
}
diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ZiplineCodeSession.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ZiplineCodeSession.kt
index 62637efb73..62316b35a4 100644
--- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ZiplineCodeSession.kt
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ZiplineCodeSession.kt
@@ -23,37 +23,39 @@ import app.cash.zipline.Zipline
import app.cash.zipline.ZiplineScope
import app.cash.zipline.withScope
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
+import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
internal class ZiplineCodeSession(
private val dispatchers: TreehouseDispatchers,
- private val appScope: CoroutineScope,
override val eventPublisher: EventPublisher,
+ frameClockFactory: FrameClock.Factory,
override val appService: A,
val zipline: Zipline,
+ val appScope: CoroutineScope,
) : CodeSession, AppLifecycle.Host {
private val listeners = mutableListOf>()
private val ziplineScope = ZiplineScope()
+ override val scope = CoroutineScope(
+ SupervisorJob(appScope.coroutineContext.job) + coroutineExceptionHandler,
+ )
+
override val json: Json
get() = zipline.json
- // These vars only accessed on TreehouseDispatchers.zipline.
- private lateinit var sessionScope: CoroutineScope
- private lateinit var frameClock: FrameClock
+ private val frameClock = frameClockFactory.create(scope, dispatchers)
private lateinit var appLifecycle: AppLifecycle
private var canceled = false
- override fun start(sessionScope: CoroutineScope, frameClock: FrameClock) {
+ override fun start() {
dispatchers.checkUi()
- sessionScope.launch(dispatchers.zipline) {
- this@ZiplineCodeSession.sessionScope = sessionScope
- this@ZiplineCodeSession.frameClock = frameClock
-
+ scope.launch(dispatchers.zipline) {
val service = appService.withScope(ziplineScope).appLifecycle
appLifecycle = service
service.start(this@ZiplineCodeSession)
@@ -81,10 +83,10 @@ internal class ZiplineCodeSession(
listener.onCancel(this)
}
- appScope.launch(dispatchers.zipline) {
- sessionScope.cancel()
+ scope.launch(dispatchers.zipline) {
ziplineScope.close()
zipline.close()
+ scope.cancel()
}
}
@@ -107,7 +109,7 @@ internal class ZiplineCodeSession(
}
override fun handleUncaughtException(exception: Throwable) {
- appScope.launch(dispatchers.ui) {
+ scope.launch(dispatchers.ui) {
val listenersArray = listeners.toTypedArray() // onUncaughtException mutates.
for (listener in listenersArray) {
listener.onUncaughtException(this@ZiplineCodeSession, exception)
diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/CodeHostTest.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/CodeHostTest.kt
new file mode 100644
index 0000000000..022f5bc250
--- /dev/null
+++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/CodeHostTest.kt
@@ -0,0 +1,299 @@
+/*
+ * Copyright (C) 2023 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package app.cash.redwood.treehouse
+
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.test.AfterTest
+import kotlin.test.Test
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+
+/** This test focuses on how [CodeHost] and its state machine. */
+@OptIn(ExperimentalCoroutinesApi::class)
+class CodeHostTest {
+ private val eventLog = EventLog()
+ private val appScope = CoroutineScope(EmptyCoroutineContext)
+
+ private val dispatcher = UnconfinedTestDispatcher()
+ private val eventPublisher = FakeEventPublisher()
+ private val dispatchers = FakeDispatchers(dispatcher, dispatcher)
+ private val codeHost = FakeCodeHost(
+ eventLog = eventLog,
+ eventPublisher = eventPublisher,
+ dispatchers = dispatchers,
+ appScope = appScope,
+ frameClockFactory = FakeFrameClock.Factory,
+ )
+ private val codeListener = FakeCodeListener(eventLog)
+ private val onBackPressedDispatcher = FakeOnBackPressedDispatcher(eventLog)
+
+ @AfterTest
+ fun tearDown() {
+ eventLog.assertNoEvents()
+ appScope.cancel()
+ }
+
+ /** Confirm that we can bind() before CodeHost.start(). */
+ @Test
+ fun bind_start_session_stop() = runTest {
+ val content = treehouseAppContent()
+ val view1 = treehouseView("view1")
+ content.bind(view1)
+ eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)")
+
+ codeHost.start()
+ eventLog.takeEvent("codeHostUpdates1.collect()")
+ codeHost.startCodeSession("codeSessionA")
+ eventLog.takeEvent("codeSessionA.start()")
+ eventLog.takeEvent("codeSessionA.app.uis[0].start()")
+
+ codeHost.stop()
+ eventLog.takeEvent("codeHostUpdates1.close()")
+ eventLog.takeEvent("codeSessionA.app.uis[0].close()")
+ eventLog.takeEvent("codeSessionA.cancel()")
+
+ content.unbind()
+ }
+
+ /** Calling CodeHost.restart() from idle starts it up. */
+ @Test
+ fun bind_restart_session_stop() = runTest {
+ val content = treehouseAppContent()
+ val view1 = treehouseView("view1")
+ content.bind(view1)
+ eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)")
+
+ codeHost.restart()
+ eventLog.takeEvent("codeHostUpdates1.collect()")
+ codeHost.startCodeSession("codeSessionA")
+ eventLog.takeEvent("codeSessionA.start()")
+ eventLog.takeEvent("codeSessionA.app.uis[0].start()")
+
+ codeHost.stop()
+ eventLog.takeEvent("codeHostUpdates1.close()")
+ eventLog.takeEvent("codeSessionA.app.uis[0].close()")
+ eventLog.takeEvent("codeSessionA.cancel()")
+
+ content.unbind()
+ }
+
+ /** CodeHost doesn't have to stay resident forever. */
+ @Test
+ fun bind_start_session_stop_start_session_stop() = runTest {
+ val content = treehouseAppContent()
+ val view1 = treehouseView("view1")
+ content.bind(view1)
+ eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)")
+
+ codeHost.start()
+ eventLog.takeEvent("codeHostUpdates1.collect()")
+ codeHost.startCodeSession("codeSessionA")
+ eventLog.takeEvent("codeSessionA.start()")
+ eventLog.takeEvent("codeSessionA.app.uis[0].start()")
+ codeHost.stop()
+ eventLog.takeEvent("codeHostUpdates1.close()")
+ eventLog.takeEvent("codeSessionA.app.uis[0].close()")
+ eventLog.takeEvent("codeSessionA.cancel()")
+
+ codeHost.start()
+ eventLog.takeEvent("codeHostUpdates2.collect()")
+ codeHost.startCodeSession("codeSessionB")
+ eventLog.takeEvent("codeSessionB.start()")
+ eventLog.takeEvent("codeSessionB.app.uis[0].start()")
+ codeHost.stop()
+ eventLog.takeEvent("codeHostUpdates2.close()")
+ eventLog.takeEvent("codeSessionB.app.uis[0].close()")
+ eventLog.takeEvent("codeSessionB.cancel()")
+
+ content.unbind()
+ }
+
+ /** CodeHost can restart() after a failure. */
+ @Test
+ fun bind_start_session_crash_restart_stop() = runTest {
+ val content = treehouseAppContent()
+ val view1 = treehouseView("view1")
+ content.bind(view1)
+ eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)")
+
+ codeHost.start()
+ eventLog.takeEvent("codeHostUpdates1.collect()")
+ val codeSessionA = codeHost.startCodeSession("codeSessionA")
+ eventLog.takeEvent("codeSessionA.start()")
+ eventLog.takeEvent("codeSessionA.app.uis[0].start()")
+ codeSessionA.handleUncaughtException(Exception("boom!"))
+ eventLog.takeEvent("codeSessionA.app.uis[0].close()")
+ eventLog.takeEvent("codeListener.onUncaughtException(view1, kotlin.Exception: boom!)")
+ eventLog.takeEvent("codeSessionA.cancel()")
+
+ codeHost.restart()
+ eventLog.takeEvent("codeHostUpdates1.close()")
+ eventLog.takeEvent("codeHostUpdates2.collect()")
+ codeHost.startCodeSession("codeSessionB")
+ eventLog.takeEvent("codeSessionB.start()")
+ eventLog.takeEvent("codeSessionB.app.uis[0].start()")
+ codeHost.stop()
+ eventLog.takeEvent("codeHostUpdates2.close()")
+ eventLog.takeEvent("codeSessionB.app.uis[0].close()")
+ eventLog.takeEvent("codeSessionB.cancel()")
+
+ content.unbind()
+ }
+
+ /** New code will also trigger a restart after a failure. */
+ @Test
+ fun bind_start_session_crash_session_stop() = runTest {
+ val content = treehouseAppContent()
+ val view1 = treehouseView("view1")
+ content.bind(view1)
+ eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)")
+
+ codeHost.start()
+ eventLog.takeEvent("codeHostUpdates1.collect()")
+ val codeSessionA = codeHost.startCodeSession("codeSessionA")
+ eventLog.takeEvent("codeSessionA.start()")
+ eventLog.takeEvent("codeSessionA.app.uis[0].start()")
+ codeSessionA.handleUncaughtException(Exception("boom!"))
+ eventLog.takeEvent("codeSessionA.app.uis[0].close()")
+ eventLog.takeEvent("codeListener.onUncaughtException(view1, kotlin.Exception: boom!)")
+ eventLog.takeEvent("codeSessionA.cancel()")
+
+ codeHost.startCodeSession("codeSessionB")
+ eventLog.takeEvent("codeSessionB.start()")
+ eventLog.takeEvent("codeSessionB.app.uis[0].start()")
+ codeHost.stop()
+ eventLog.takeEvent("codeHostUpdates1.close()")
+ eventLog.takeEvent("codeSessionB.app.uis[0].close()")
+ eventLog.takeEvent("codeSessionB.cancel()")
+
+ content.unbind()
+ }
+
+ /** We can stop after a failure. */
+ @Test
+ fun bind_start_session_crash_stop() = runTest {
+ val content = treehouseAppContent()
+ val view1 = treehouseView("view1")
+ content.bind(view1)
+ eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)")
+
+ codeHost.start()
+ eventLog.takeEvent("codeHostUpdates1.collect()")
+ val codeSessionA = codeHost.startCodeSession("codeSessionA")
+ eventLog.takeEvent("codeSessionA.start()")
+ eventLog.takeEvent("codeSessionA.app.uis[0].start()")
+ codeSessionA.handleUncaughtException(Exception("boom!"))
+ eventLog.takeEvent("codeSessionA.app.uis[0].close()")
+ eventLog.takeEvent("codeListener.onUncaughtException(view1, kotlin.Exception: boom!)")
+ eventLog.takeEvent("codeSessionA.cancel()")
+
+ codeHost.stop()
+ eventLog.takeEvent("codeHostUpdates1.close()")
+
+ content.unbind()
+ }
+
+ /** Calling start() while it's starting is a no-op. */
+ @Test
+ fun bind_start_start_session() = runTest {
+ val content = treehouseAppContent()
+ val view1 = treehouseView("view1")
+ content.bind(view1)
+ eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)")
+
+ codeHost.start()
+ eventLog.takeEvent("codeHostUpdates1.collect()")
+ eventLog.assertNoEvents()
+
+ codeHost.start()
+ eventLog.assertNoEvents()
+
+ codeHost.startCodeSession("codeSessionA")
+ eventLog.takeEvent("codeSessionA.start()")
+ eventLog.takeEvent("codeSessionA.app.uis[0].start()")
+ codeHost.stop()
+ eventLog.takeEvent("codeHostUpdates1.close()")
+ eventLog.takeEvent("codeSessionA.app.uis[0].close()")
+ eventLog.takeEvent("codeSessionA.cancel()")
+
+ content.unbind()
+ }
+
+ /** Calling start() while it's running is a no-op. */
+ @Test
+ fun bind_start_session_start_stop() = runTest {
+ val content = treehouseAppContent()
+ val view1 = treehouseView("view1")
+ content.bind(view1)
+ eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)")
+
+ codeHost.start()
+ eventLog.takeEvent("codeHostUpdates1.collect()")
+ codeHost.startCodeSession("codeSessionA")
+ eventLog.takeEvent("codeSessionA.start()")
+ eventLog.takeEvent("codeSessionA.app.uis[0].start()")
+
+ codeHost.start()
+ eventLog.assertNoEvents()
+
+ codeHost.stop()
+ eventLog.takeEvent("codeHostUpdates1.close()")
+ eventLog.takeEvent("codeSessionA.app.uis[0].close()")
+ eventLog.takeEvent("codeSessionA.cancel()")
+
+ content.unbind()
+ }
+
+ /** Calling stop() while it's idle is a no-op. */
+ @Test
+ fun bind_start_session_stop_stop() = runTest {
+ val content = treehouseAppContent()
+ val view1 = treehouseView("view1")
+ content.bind(view1)
+ eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)")
+
+ codeHost.start()
+ eventLog.takeEvent("codeHostUpdates1.collect()")
+ codeHost.startCodeSession("codeSessionA")
+ eventLog.takeEvent("codeSessionA.start()")
+ eventLog.takeEvent("codeSessionA.app.uis[0].start()")
+
+ codeHost.stop()
+ eventLog.takeEvent("codeHostUpdates1.close()")
+ eventLog.takeEvent("codeSessionA.app.uis[0].close()")
+ eventLog.takeEvent("codeSessionA.cancel()")
+
+ codeHost.stop()
+
+ content.unbind()
+ }
+
+ private fun treehouseAppContent(): TreehouseAppContent {
+ return TreehouseAppContent(
+ codeHost = codeHost,
+ dispatchers = dispatchers,
+ codeListener = codeListener,
+ source = { app -> app.newUi() },
+ )
+ }
+
+ private fun treehouseView(name: String): FakeTreehouseView {
+ return FakeTreehouseView(onBackPressedDispatcher, name)
+ }
+}
diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeHost.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeHost.kt
index d769c4b6c1..d1633b83b7 100644
--- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeHost.kt
+++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeHost.kt
@@ -15,61 +15,44 @@
*/
package app.cash.redwood.treehouse
-import app.cash.redwood.treehouse.CodeHost.Listener
-import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.consumeAsFlow
internal class FakeCodeHost(
private val eventLog: EventLog,
private val eventPublisher: EventPublisher,
-) : CodeHost {
- override val stateStore = MemoryStateStore()
-
- private val codeSessionListener = object : CodeSession.Listener {
- override fun onUncaughtException(
- codeSession: CodeSession,
- exception: Throwable,
- ) {
- }
-
- override fun onCancel(
- codeSession: CodeSession,
- ) {
- check(codeSession == this@FakeCodeHost.session)
- this@FakeCodeHost.session = null
+ private val dispatchers: TreehouseDispatchers,
+ private val appScope: CoroutineScope,
+ frameClockFactory: FrameClock.Factory,
+) : CodeHost(
+ dispatchers = dispatchers,
+ appScope = appScope,
+ frameClockFactory = frameClockFactory,
+ stateStore = MemoryStateStore(),
+) {
+ private var codeSessions: Channel>? = null
+ private var nextCollectId = 1
+
+ /**
+ * Create a new channel every time we subscribe to code updates. The channel will be closed when
+ * the superclass is done consuming the flow.
+ */
+ override fun codeUpdatesFlow(): Flow> {
+ val collectId = nextCollectId++
+ eventLog += "codeHostUpdates$collectId.collect()"
+ val channel = Channel>(Int.MAX_VALUE)
+ channel.invokeOnClose {
+ eventLog += "codeHostUpdates$collectId.close()"
}
+ codeSessions = channel
+ return channel.consumeAsFlow()
}
- override var session: CodeSession? = null
- set(value) {
- val previous = field
- previous?.removeListener(codeSessionListener)
- previous?.cancel()
-
- if (value != null) {
- value.start(CoroutineScope(EmptyCoroutineContext), FakeFrameClock())
- for (listener in listeners) {
- listener.codeSessionChanged(value)
- }
- }
-
- value?.addListener(codeSessionListener)
- field = value
- }
-
- private val listeners = mutableListOf>()
-
- fun startCodeSession(name: String): CodeSession {
- val result = FakeCodeSession(eventLog, name, eventPublisher)
- session = result
+ suspend fun startCodeSession(name: String): CodeSession {
+ val result = FakeCodeSession(dispatchers, eventPublisher, eventLog, name, appScope)
+ codeSessions!!.send(result)
return result
}
-
- override fun addListener(listener: Listener) {
- listeners += listener
- }
-
- override fun removeListener(listener: Listener) {
- listeners -= listener
- }
}
diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeSession.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeSession.kt
index 4658c43d6e..c835773489 100644
--- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeSession.kt
+++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeSession.kt
@@ -18,22 +18,32 @@ package app.cash.redwood.treehouse
import app.cash.redwood.treehouse.CodeSession.Listener
import app.cash.redwood.treehouse.CodeSession.ServiceScope
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.job
+import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
internal class FakeCodeSession(
+ private val dispatchers: TreehouseDispatchers,
+ override val eventPublisher: EventPublisher,
private val eventLog: EventLog,
private val name: String,
- override val eventPublisher: EventPublisher,
+ appScope: CoroutineScope,
) : CodeSession {
private val listeners = mutableListOf>()
+ override val scope = CoroutineScope(
+ SupervisorJob(appScope.coroutineContext.job) + coroutineExceptionHandler,
+ )
+
override val json = Json
override val appService = FakeAppService("$name.app", eventLog)
private var canceled = false
- override fun start(sessionScope: CoroutineScope, frameClock: FrameClock) {
+ override fun start() {
eventLog += "$name.start()"
}
@@ -83,5 +93,11 @@ internal class FakeCodeSession(
}
eventLog += "$name.cancel()"
+
+ // Cancel the scope asynchronously for consistency with ZiplineCodeSession. This is important
+ // because Listener.onCancel() enqueues work on this scope, and we need that to run.
+ scope.launch(dispatchers.zipline) {
+ scope.cancel()
+ }
}
}
diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeFrameClock.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeFrameClock.kt
index d648cbcb5a..ed188d837c 100644
--- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeFrameClock.kt
+++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeFrameClock.kt
@@ -15,7 +15,18 @@
*/
package app.cash.redwood.treehouse
-class FakeFrameClock : FrameClock {
+import kotlinx.coroutines.CoroutineScope
+
+internal class FakeFrameClock : FrameClock {
override fun requestFrame(appLifecycle: AppLifecycle) {
}
+
+ object Factory : FrameClock.Factory {
+ override fun create(
+ scope: CoroutineScope,
+ dispatchers: TreehouseDispatchers,
+ ): FrameClock {
+ return FakeFrameClock()
+ }
+ }
}
diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeOnBackPressedDispatcher.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeOnBackPressedDispatcher.kt
index f574e37ff9..9f77ba4587 100644
--- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeOnBackPressedDispatcher.kt
+++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeOnBackPressedDispatcher.kt
@@ -19,17 +19,27 @@ import app.cash.redwood.ui.Cancellable
import app.cash.redwood.ui.OnBackPressedCallback
import app.cash.redwood.ui.OnBackPressedDispatcher
-internal class FakeOnBackPressedDispatcher : OnBackPressedDispatcher {
+internal class FakeOnBackPressedDispatcher(
+ val eventLog: EventLog,
+) : OnBackPressedDispatcher {
private val mutableCallbacks = mutableListOf()
+ private var nextCallbackId = 0
val callbacks: List
get() = mutableCallbacks.toList()
override fun addCallback(onBackPressedCallback: OnBackPressedCallback): Cancellable {
+ val id = nextCallbackId++
mutableCallbacks += onBackPressedCallback
return object : Cancellable {
+ var canceled = false
+
override fun cancel() {
+ if (canceled) return
+ canceled = true
+
+ eventLog += "onBackPressedDispatcher.callbacks[$id].cancel()"
mutableCallbacks -= onBackPressedCallback
}
}
diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeTreehouseView.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeTreehouseView.kt
index 288703cbaa..920aeaa6ff 100644
--- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeTreehouseView.kt
+++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeTreehouseView.kt
@@ -22,6 +22,7 @@ import app.cash.redwood.widget.SavedStateRegistry
import kotlinx.coroutines.flow.MutableStateFlow
internal class FakeTreehouseView(
+ override val onBackPressedDispatcher: FakeOnBackPressedDispatcher,
private val name: String,
) : TreehouseView {
override val widgetSystem = FakeWidgetSystem()
@@ -36,13 +37,10 @@ internal class FakeTreehouseView(
override var saveCallback: TreehouseView.SaveCallback? = null
- override val stateSnapshotId: StateSnapshot.Id
- get() = error("unexpected call")
+ override val stateSnapshotId: StateSnapshot.Id = StateSnapshot.Id(null)
override val children = MutableListChildren()
- override val onBackPressedDispatcher = FakeOnBackPressedDispatcher()
-
override val uiConfiguration = MutableStateFlow(UiConfiguration())
override val savedStateRegistry: SavedStateRegistry? = null
diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt
index bc7841bee1..30a3ca1cd2 100644
--- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt
+++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt
@@ -20,35 +20,59 @@ import assertk.assertThat
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isNotEmpty
+import kotlin.coroutines.EmptyCoroutineContext
import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
+/**
+ * This test focuses on how [TreehouseAppContent] behaves in response to lifecycle events from the
+ * code ([CodeSession]), the UI ([TreehouseView]), and the content ([ZiplineTreehouseUi]).
+ */
@OptIn(ExperimentalCoroutinesApi::class)
class TreehouseAppContentTest {
private val eventLog = EventLog()
+ private val appScope = CoroutineScope(EmptyCoroutineContext)
private val dispatcher = UnconfinedTestDispatcher()
private val eventPublisher = FakeEventPublisher()
- private val codeHost = FakeCodeHost(eventLog, eventPublisher)
private val dispatchers = FakeDispatchers(dispatcher, dispatcher)
+ private val onBackPressedDispatcher = FakeOnBackPressedDispatcher(eventLog)
+ private val codeHost = FakeCodeHost(
+ eventLog = eventLog,
+ eventPublisher = eventPublisher,
+ dispatchers = dispatchers,
+ appScope = appScope,
+ frameClockFactory = FakeFrameClock.Factory,
+ )
private val codeListener = FakeCodeListener(eventLog)
private val uiConfiguration = UiConfiguration()
+ @BeforeTest
+ fun setUp() {
+ runBlocking {
+ codeHost.start()
+ eventLog.takeEvent("codeHostUpdates1.collect()")
+ }
+ }
+
@AfterTest
fun tearDown() {
eventLog.assertNoEvents()
+ appScope.cancel()
}
@Test
fun bind_session_addWidget_unbind() = runTest {
val content = treehouseAppContent()
- val view1 = FakeTreehouseView("view1")
+ val view1 = treehouseView("view1")
content.bind(view1)
eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)")
@@ -68,7 +92,7 @@ class TreehouseAppContentTest {
fun preload_session_addWidget_bind_unbind() = runTest {
val content = treehouseAppContent()
- content.preload(FakeOnBackPressedDispatcher(), uiConfiguration)
+ content.preload(onBackPressedDispatcher, uiConfiguration)
eventLog.assertNoEvents()
val codeSessionA = codeHost.startCodeSession("codeSessionA")
@@ -79,7 +103,7 @@ class TreehouseAppContentTest {
codeSessionA.appService.uis.single().addWidget("hello")
eventLog.assertNoEvents()
- val view1 = FakeTreehouseView("view1")
+ val view1 = treehouseView("view1")
content.bind(view1)
eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)")
@@ -94,10 +118,10 @@ class TreehouseAppContentTest {
val codeSessionA = codeHost.startCodeSession("codeSessionA")
eventLog.takeEvent("codeSessionA.start()")
- content.preload(FakeOnBackPressedDispatcher(), uiConfiguration)
+ content.preload(onBackPressedDispatcher, uiConfiguration)
eventLog.takeEvent("codeSessionA.app.uis[0].start()")
- val view1 = FakeTreehouseView("view1")
+ val view1 = treehouseView("view1")
content.bind(view1)
eventLog.assertNoEvents()
@@ -116,7 +140,7 @@ class TreehouseAppContentTest {
val codeSessionA = codeHost.startCodeSession("codeSessionA")
eventLog.takeEvent("codeSessionA.start()")
- val view1 = FakeTreehouseView("view1")
+ val view1 = treehouseView("view1")
content.bind(view1)
eventLog.takeEvent("codeSessionA.app.uis[0].start()")
@@ -133,7 +157,7 @@ class TreehouseAppContentTest {
fun bind_sessionA_sessionB_unbind() = runTest {
val content = treehouseAppContent()
- val view1 = FakeTreehouseView("view1")
+ val view1 = treehouseView("view1")
content.bind(view1)
eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)")
@@ -144,15 +168,16 @@ class TreehouseAppContentTest {
eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)")
val codeSessionB = codeHost.startCodeSession("codeSessionB")
- eventLog.takeEvent("codeSessionA.cancel()")
- eventLog.takeEvent("codeSessionB.start()")
- eventLog.takeEvent("codeSessionB.app.uis[0].start()")
+ eventLog.takeEventsInAnyOrder(
+ "codeSessionA.app.uis[0].close()",
+ "codeSessionA.cancel()",
+ "codeSessionB.start()",
+ "codeSessionB.app.uis[0].start()",
+ )
// This still shows UI from codeSessionA. There's no onCodeLoaded() and no reset() until the new
// code's first widget is added!
assertThat(view1.children.single().value.label).isEqualTo("helloA")
- eventLog.takeEvent("codeSessionA.app.uis[0].close()")
-
codeSessionB.appService.uis.single().addWidget("helloB")
eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = false)")
assertThat(view1.children.single().value.label).isEqualTo("helloB")
@@ -165,7 +190,7 @@ class TreehouseAppContentTest {
fun preload_unbind_session() = runTest {
val content = treehouseAppContent()
- content.preload(FakeOnBackPressedDispatcher(), uiConfiguration)
+ content.preload(onBackPressedDispatcher, uiConfiguration)
eventLog.assertNoEvents()
content.unbind()
@@ -180,7 +205,7 @@ class TreehouseAppContentTest {
fun bind_unbind_session() = runTest {
val content = treehouseAppContent()
- val view1 = FakeTreehouseView("view1")
+ val view1 = treehouseView("view1")
content.bind(view1)
eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)")
@@ -203,7 +228,7 @@ class TreehouseAppContentTest {
val codeSessionA = codeHost.startCodeSession("codeSessionA")
eventLog.takeEvent("codeSessionA.start()")
- val view1 = FakeTreehouseView("view1")
+ val view1 = treehouseView("view1")
content.bind(view1)
eventLog.takeEvent("codeSessionA.app.uis[0].start()")
@@ -229,7 +254,7 @@ class TreehouseAppContentTest {
fun addBackHandler_receives_back_presses_until_canceled() = runTest {
val content = treehouseAppContent()
- val view1 = FakeTreehouseView("view1")
+ val view1 = treehouseView("view1")
content.bind(view1)
val codeSessionA = codeHost.startCodeSession("codeSessionA")
eventLog.clear()
@@ -242,6 +267,8 @@ class TreehouseAppContentTest {
eventLog.takeEvent("codeSessionA.app.uis[0].onBackPressed()")
backCancelable.cancel()
+ eventLog.takeEvent("onBackPressedDispatcher.callbacks[0].cancel()")
+
view1.onBackPressedDispatcher.onBack()
eventLog.assertNoEvents()
@@ -253,7 +280,7 @@ class TreehouseAppContentTest {
fun addBackHandler_receives_no_back_presses_if_disabled() = runTest {
val content = treehouseAppContent()
- val view1 = FakeTreehouseView("view1")
+ val view1 = treehouseView("view1")
content.bind(view1)
val codeSessionA = codeHost.startCodeSession("codeSessionA")
eventLog.clear()
@@ -265,6 +292,7 @@ class TreehouseAppContentTest {
backCancelable.cancel()
content.unbind()
+ eventLog.takeEvent("onBackPressedDispatcher.callbacks[0].cancel()")
eventLog.takeEvent("codeSessionA.app.uis[0].close()")
}
@@ -272,18 +300,25 @@ class TreehouseAppContentTest {
fun backHandlers_cleared_when_session_changes() = runTest {
val content = treehouseAppContent()
- val view1 = FakeTreehouseView("view1")
+ val view1 = treehouseView("view1")
content.bind(view1)
val codeSessionA = codeHost.startCodeSession("codeSessionA")
codeSessionA.appService.uis.single().addBackHandler(true)
assertThat(view1.onBackPressedDispatcher.callbacks).isNotEmpty()
+ eventLog.clear()
codeHost.startCodeSession("codeSessionB")
// When we close codeSessionA, its back handlers are released with it.
+ eventLog.takeEventsInAnyOrder(
+ "codeSessionA.app.uis[0].close()",
+ "onBackPressedDispatcher.callbacks[0].cancel()",
+ "codeSessionA.cancel()",
+ "codeSessionB.start()",
+ "codeSessionB.app.uis[0].start()",
+ )
assertThat(view1.onBackPressedDispatcher.callbacks).isEmpty()
- eventLog.clear()
content.unbind()
eventLog.takeEvent("codeSessionB.app.uis[0].close()")
@@ -296,7 +331,7 @@ class TreehouseAppContentTest {
val codeSessionA = codeHost.startCodeSession("codeSessionA")
eventLog.takeEvent("codeSessionA.start()")
- val view1 = FakeTreehouseView("view1")
+ val view1 = treehouseView("view1")
content.bind(view1)
eventLog.takeEvent("codeSessionA.app.uis[0].start()")
@@ -320,7 +355,7 @@ class TreehouseAppContentTest {
codeSessionA.handleUncaughtException(Exception("boom!"))
eventLog.takeEvent("codeSessionA.cancel()")
- val view1 = FakeTreehouseView("view1")
+ val view1 = treehouseView("view1")
content.bind(view1)
eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)")
@@ -339,7 +374,7 @@ class TreehouseAppContentTest {
val codeSessionA = codeHost.startCodeSession("codeSessionA")
eventLog.takeEvent("codeSessionA.start()")
- val view1 = FakeTreehouseView("view1")
+ val view1 = treehouseView("view1")
content.bind(view1)
eventLog.takeEvent("codeSessionA.app.uis[0].start()")
@@ -369,27 +404,30 @@ class TreehouseAppContentTest {
val codeSessionA = codeHost.startCodeSession("codeSessionA")
eventLog.takeEvent("codeSessionA.start()")
- content.preload(FakeOnBackPressedDispatcher(), uiConfiguration)
+ content.preload(onBackPressedDispatcher, uiConfiguration)
eventLog.takeEvent("codeSessionA.app.uis[0].start()")
codeSessionA.handleUncaughtException(Exception("boom!"))
eventLog.takeEvent("codeSessionA.app.uis[0].close()")
eventLog.takeEvent("codeSessionA.cancel()")
- val view1 = FakeTreehouseView("view1")
+ val view1 = treehouseView("view1")
content.bind(view1)
eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)")
content.unbind()
}
- private fun TestScope.treehouseAppContent(): TreehouseAppContent {
+ private fun treehouseAppContent(): TreehouseAppContent {
return TreehouseAppContent(
codeHost = codeHost,
dispatchers = dispatchers,
- appScope = CoroutineScope(coroutineContext),
codeListener = codeListener,
source = { app -> app.newUi() },
)
}
+
+ private fun treehouseView(name: String): FakeTreehouseView {
+ return FakeTreehouseView(onBackPressedDispatcher, name)
+ }
}